Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
use warpui::{
|
||||
elements::{ChildView, Container, Dismiss, Empty},
|
||||
ui_components::components::UiComponent,
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
ui_components::dialog::{dialog_styles, Dialog},
|
||||
view_components::action_button::{ActionButton, DangerPrimaryTheme, NakedTheme},
|
||||
};
|
||||
|
||||
const DIALOG_WIDTH: f32 = 450.;
|
||||
pub enum DestructiveMCPConfirmationDialogEvent {
|
||||
Cancel,
|
||||
Confirm(DestructiveMCPConfirmationDialogVariant),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DestructiveMCPConfirmationDialogAction {
|
||||
Cancel,
|
||||
Confirm,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DestructiveMCPConfirmationDialogDisplayOptions {
|
||||
title_text: String,
|
||||
description_text: String,
|
||||
confirm_button_label: String,
|
||||
cancel_button_label: String,
|
||||
}
|
||||
|
||||
impl DestructiveMCPConfirmationDialogDisplayOptions {
|
||||
pub fn new(
|
||||
title_text: String,
|
||||
description_text: String,
|
||||
confirm_button_label: String,
|
||||
cancel_button_label: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
title_text,
|
||||
description_text,
|
||||
confirm_button_label,
|
||||
cancel_button_label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DestructiveMCPConfirmationDialogVariant {
|
||||
DeleteLocal,
|
||||
DeleteShared,
|
||||
Unshare,
|
||||
}
|
||||
|
||||
impl From<&DestructiveMCPConfirmationDialogVariant>
|
||||
for DestructiveMCPConfirmationDialogDisplayOptions
|
||||
{
|
||||
fn from(variant: &DestructiveMCPConfirmationDialogVariant) -> Self {
|
||||
match *variant {
|
||||
DestructiveMCPConfirmationDialogVariant::DeleteLocal => DestructiveMCPConfirmationDialogDisplayOptions::new(
|
||||
"Delete MCP server?".to_string(),
|
||||
"This will uninstall and remove this MCP server from all your devices.".to_string(),
|
||||
"Delete MCP".to_string(),
|
||||
"Cancel".to_string(),
|
||||
),
|
||||
DestructiveMCPConfirmationDialogVariant::DeleteShared => DestructiveMCPConfirmationDialogDisplayOptions::new(
|
||||
"Delete shared MCP server?".to_string(),
|
||||
"This will not only delete this MCP server for yourself, but also uninstall and remove this MCP server from Warp and across all of your teammates' devices.".to_string(),
|
||||
"Delete MCP".to_string(),
|
||||
"Cancel".to_string(),
|
||||
),
|
||||
DestructiveMCPConfirmationDialogVariant::Unshare => DestructiveMCPConfirmationDialogDisplayOptions::new(
|
||||
"Remove shared MCP server from team?".to_string(),
|
||||
"This will uninstall and remove this MCP server from Warp and across all of your teammates' devices.".to_string(),
|
||||
"Remove from team".to_string(),
|
||||
"Cancel".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DestructiveMCPConfirmationDialog {
|
||||
visible: bool,
|
||||
variant: DestructiveMCPConfirmationDialogVariant,
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
confirm_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl DestructiveMCPConfirmationDialog {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let cancel_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("", NakedTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(DestructiveMCPConfirmationDialogAction::Cancel);
|
||||
})
|
||||
});
|
||||
|
||||
let confirm_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("", DangerPrimaryTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(DestructiveMCPConfirmationDialogAction::Confirm);
|
||||
})
|
||||
});
|
||||
|
||||
Self {
|
||||
visible: false,
|
||||
variant: DestructiveMCPConfirmationDialogVariant::DeleteLocal,
|
||||
cancel_button,
|
||||
confirm_button,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
&mut self,
|
||||
variant: DestructiveMCPConfirmationDialogVariant,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let display_options: DestructiveMCPConfirmationDialogDisplayOptions = (&variant).into();
|
||||
|
||||
self.cancel_button.update(ctx, |button, ctx| {
|
||||
button.set_label(display_options.cancel_button_label.clone(), ctx);
|
||||
});
|
||||
self.confirm_button.update(ctx, |button, ctx| {
|
||||
button.set_label(display_options.confirm_button_label.clone(), ctx);
|
||||
});
|
||||
|
||||
self.variant = variant;
|
||||
self.visible = true;
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn hide(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.visible = false;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DestructiveMCPConfirmationDialog {
|
||||
type Event = DestructiveMCPConfirmationDialogEvent;
|
||||
}
|
||||
|
||||
impl View for DestructiveMCPConfirmationDialog {
|
||||
fn ui_name() -> &'static str {
|
||||
"DestructiveMCPConfirmationDialog"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
if !self.visible {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let display_options: DestructiveMCPConfirmationDialogDisplayOptions =
|
||||
(&self.variant).into();
|
||||
|
||||
let dialog = Dialog::new(
|
||||
display_options.title_text.clone(),
|
||||
Some(display_options.description_text.clone()),
|
||||
dialog_styles(appearance),
|
||||
)
|
||||
.with_bottom_row_child(ChildView::new(&self.cancel_button).finish())
|
||||
.with_bottom_row_child(
|
||||
Container::new(ChildView::new(&self.confirm_button).finish())
|
||||
.with_margin_left(12.)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(DIALOG_WIDTH)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
Dismiss::new(dialog)
|
||||
.prevent_interaction_with_other_elements()
|
||||
.on_dismiss(|ctx, _app| {
|
||||
ctx.dispatch_typed_action(DestructiveMCPConfirmationDialogAction::Cancel)
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for DestructiveMCPConfirmationDialog {
|
||||
type Action = DestructiveMCPConfirmationDialogAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
DestructiveMCPConfirmationDialogAction::Cancel => {
|
||||
ctx.emit(DestructiveMCPConfirmationDialogEvent::Cancel)
|
||||
}
|
||||
DestructiveMCPConfirmationDialogAction::Confirm => ctx.emit(
|
||||
DestructiveMCPConfirmationDialogEvent::Confirm(self.variant.clone()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use diesel::SqliteConnection;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use parking_lot::Mutex;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use uuid::Uuid;
|
||||
use warp_core::{
|
||||
send_telemetry_from_ctx,
|
||||
ui::{appearance::Appearance, theme::color::internal_colors},
|
||||
};
|
||||
use warp_editor::{
|
||||
content::buffer::InitialBufferState, render::element::VerticalExpansionBehavior,
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Flex,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack, Text,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::UiComponent,
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
blocklist::secret_redaction::find_secrets_in_text,
|
||||
mcp::{
|
||||
parsing::{prettify_json, resolve_json, ParsedTemplatableMCPServerResult},
|
||||
templatable::CloudTemplatableMCPServer,
|
||||
MCPServer, TemplatableMCPServer, TemplatableMCPServerInstallation,
|
||||
TemplatableMCPServerManager, TransportType,
|
||||
},
|
||||
},
|
||||
banner::{Banner, BannerTextContent},
|
||||
cloud_object::{CloudObject, Space},
|
||||
code::editor::view::{CodeEditorRenderOptions, CodeEditorView},
|
||||
persistence::ModelEvent,
|
||||
server::{
|
||||
cloud_objects::update_manager::InitiatedBy,
|
||||
telemetry::{MCPTemplateCreationSource, TelemetryEvent},
|
||||
},
|
||||
settings_view::mcp_servers::{
|
||||
destructive_mcp_confirmation_dialog::{
|
||||
DestructiveMCPConfirmationDialog, DestructiveMCPConfirmationDialogEvent,
|
||||
DestructiveMCPConfirmationDialogVariant,
|
||||
},
|
||||
style, ServerCardItemId,
|
||||
},
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
view_components::{
|
||||
action_button::{ActionButton, DangerNakedTheme, DangerSecondaryTheme, PrimaryTheme},
|
||||
DismissibleToast,
|
||||
},
|
||||
workspace::ToastStack,
|
||||
GlobalResourceHandlesProvider,
|
||||
};
|
||||
|
||||
const DEFAULT_JSON_TEXT: &str = r#"{
|
||||
"": {
|
||||
"serverUrl": ""
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MCPServersEditPageViewEvent {
|
||||
Back,
|
||||
Reinstall(Uuid),
|
||||
Delete(ServerCardItemId),
|
||||
LogOut(ServerCardItemId, Option<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MCPServersEditPageViewAction {
|
||||
Back,
|
||||
Reinstall,
|
||||
Save,
|
||||
Delete,
|
||||
Unshare,
|
||||
LogOut,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ServerModel {
|
||||
CloudTemplatableMCPServer(CloudTemplatableMCPServer),
|
||||
LocalTemplatableMCPInstallation(TemplatableMCPServerInstallation),
|
||||
None,
|
||||
}
|
||||
|
||||
impl ServerModel {
|
||||
pub fn name(&self) -> Option<String> {
|
||||
match self {
|
||||
ServerModel::CloudTemplatableMCPServer(cloud_templatable_server) => {
|
||||
Some(cloud_templatable_server.display_name())
|
||||
}
|
||||
ServerModel::LocalTemplatableMCPInstallation(templatable_mcp_server_installation) => {
|
||||
Some(
|
||||
templatable_mcp_server_installation
|
||||
.templatable_mcp_server()
|
||||
.name
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
ServerModel::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MCPServersEditPageView {
|
||||
server_card_item_id: Option<ServerCardItemId>,
|
||||
server_model: ServerModel,
|
||||
save_button: ViewHandle<ActionButton>,
|
||||
reinstall_button: ViewHandle<ActionButton>,
|
||||
delete_button: ViewHandle<ActionButton>,
|
||||
unshare_button: ViewHandle<ActionButton>,
|
||||
back_button: MouseStateHandle,
|
||||
json_editor: ViewHandle<CodeEditorView>,
|
||||
destructive_mcp_confirmation_dialog: ViewHandle<DestructiveMCPConfirmationDialog>,
|
||||
log_out_icon_button_mouse_handle: MouseStateHandle,
|
||||
editing_disabled_banner: ViewHandle<Banner<()>>,
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[allow(dead_code)]
|
||||
database_connection: Option<Arc<Mutex<SqliteConnection>>>,
|
||||
}
|
||||
|
||||
impl MCPServersEditPageView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let save_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Save", PrimaryTheme)
|
||||
.with_icon(Icon::Check)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(MCPServersEditPageViewAction::Save);
|
||||
})
|
||||
});
|
||||
|
||||
let reinstall_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Edit Variables", PrimaryTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(MCPServersEditPageViewAction::Reinstall);
|
||||
})
|
||||
});
|
||||
|
||||
let delete_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Delete MCP", DangerSecondaryTheme)
|
||||
.with_icon(Icon::Trash)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(MCPServersEditPageViewAction::Delete);
|
||||
})
|
||||
});
|
||||
|
||||
let unshare_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Remove from team", DangerNakedTheme)
|
||||
.with_icon(Icon::MinusCircle)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(MCPServersEditPageViewAction::Unshare);
|
||||
})
|
||||
});
|
||||
|
||||
let json_editor = ctx.add_typed_action_view(|ctx| {
|
||||
#[cfg_attr(target_family = "wasm", allow(unused_mut))]
|
||||
let mut editor = CodeEditorView::new(
|
||||
None,
|
||||
None,
|
||||
CodeEditorRenderOptions::new(VerticalExpansionBehavior::FillMaxHeight),
|
||||
ctx,
|
||||
)
|
||||
.with_horizontal_scrollbar_appearance(
|
||||
warpui::elements::new_scrollable::ScrollableAppearance::new(
|
||||
warpui::elements::ScrollbarWidth::Auto,
|
||||
true,
|
||||
),
|
||||
);
|
||||
editor.set_language_with_path(Path::new("mcp.json"), ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
let destructive_mcp_confirmation_dialog =
|
||||
ctx.add_typed_action_view(DestructiveMCPConfirmationDialog::new);
|
||||
ctx.subscribe_to_view(&destructive_mcp_confirmation_dialog, |me, _, event, ctx| {
|
||||
me.handle_delete_confirmation_event(event, ctx);
|
||||
});
|
||||
|
||||
let editing_disabled_banner = ctx.add_typed_action_view(|_| {
|
||||
Banner::new_without_close(BannerTextContent::plain_text(
|
||||
"Only team admins and the creator of the MCP server can edit the MCP server.",
|
||||
))
|
||||
.with_icon(Icon::Warning)
|
||||
});
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let database_connection =
|
||||
crate::persistence::database_file_path()
|
||||
.to_str()
|
||||
.and_then(|db_url| {
|
||||
crate::persistence::establish_ro_connection(db_url)
|
||||
.ok()
|
||||
.map(|conn| Arc::new(Mutex::new(conn)))
|
||||
});
|
||||
|
||||
Self {
|
||||
server_card_item_id: None,
|
||||
server_model: ServerModel::None,
|
||||
save_button,
|
||||
reinstall_button,
|
||||
delete_button,
|
||||
unshare_button,
|
||||
back_button: Default::default(),
|
||||
json_editor,
|
||||
destructive_mcp_confirmation_dialog,
|
||||
log_out_icon_button_mouse_handle: Default::default(),
|
||||
editing_disabled_banner,
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
database_connection,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_mcp_server(
|
||||
&mut self,
|
||||
item_id: Option<ServerCardItemId>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.server_card_item_id = item_id;
|
||||
match item_id {
|
||||
Some(ServerCardItemId::TemplatableMCP(template_uuid)) => {
|
||||
let cloud_templatable_mcp_server = TemplatableMCPServerManager::as_ref(ctx)
|
||||
.get_cloud_templatable_mcp_server(template_uuid);
|
||||
|
||||
if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server {
|
||||
self.server_model = ServerModel::CloudTemplatableMCPServer(
|
||||
cloud_templatable_mcp_server.clone(),
|
||||
);
|
||||
let templatable_mcp_server = &cloud_templatable_mcp_server.model().string_model;
|
||||
let json = templatable_mcp_server.to_user_json();
|
||||
|
||||
self.json_editor.update(ctx, |view, ctx| {
|
||||
let state = InitialBufferState::plain_text(&json);
|
||||
view.reset(state, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(ServerCardItemId::TemplatableMCPInstallation(installation_uuid)) => {
|
||||
let installation = TemplatableMCPServerManager::as_ref(ctx)
|
||||
.get_installed_server(&installation_uuid);
|
||||
|
||||
if let Some(installation) = installation {
|
||||
self.server_model =
|
||||
ServerModel::LocalTemplatableMCPInstallation(installation.clone());
|
||||
// This shouldn't be necessary for newly created mcps but some older ones may not have been saved with pretty json
|
||||
let resolved_json = prettify_json(&resolve_json(installation));
|
||||
|
||||
self.json_editor.update(ctx, |view, ctx| {
|
||||
let state = InitialBufferState::plain_text(&resolved_json);
|
||||
view.reset(state, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(ServerCardItemId::GalleryMCP(_uuid)) => {
|
||||
log::warn!("Editing of gallery MCP unimplemented");
|
||||
}
|
||||
Some(ServerCardItemId::FileBasedMCP(_)) => {
|
||||
log::warn!("Editing of file-based MCP unimplemented");
|
||||
}
|
||||
None => {
|
||||
self.server_model = ServerModel::None;
|
||||
self.json_editor.update(ctx, |view, ctx| {
|
||||
let state = InitialBufferState::plain_text(DEFAULT_JSON_TEXT);
|
||||
view.reset(state, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if Self::is_editable(item_id, ctx) {
|
||||
self.json_editor.update(ctx, |editor, ctx| {
|
||||
editor.set_interaction_state(crate::editor::InteractionState::Editable, ctx);
|
||||
});
|
||||
} else {
|
||||
self.json_editor.update(ctx, |editor, ctx| {
|
||||
editor.set_interaction_state(crate::editor::InteractionState::Selectable, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn should_show_oauth_components(&self, ctx: &AppContext) -> bool {
|
||||
if let Some(item_id) = self.server_card_item_id {
|
||||
match item_id {
|
||||
ServerCardItemId::TemplatableMCP(_) => false,
|
||||
ServerCardItemId::TemplatableMCPInstallation(uuid) => {
|
||||
let template_uuid =
|
||||
TemplatableMCPServerManager::as_ref(ctx).get_template_uuid(uuid);
|
||||
if let Some(template_uuid) = template_uuid {
|
||||
TemplatableMCPServerManager::as_ref(ctx)
|
||||
.has_oauth_credentials_for_server(template_uuid)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
ServerCardItemId::GalleryMCP(_) | ServerCardItemId::FileBasedMCP(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let title = if self.server_card_item_id.is_none() {
|
||||
"Add New MCP Server".to_string()
|
||||
} else if let Some(name) = self.server_model.name() {
|
||||
format!("Edit {name} MCP Server")
|
||||
} else {
|
||||
"Edit MCP Server".to_string()
|
||||
};
|
||||
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
let log_out_icon_button = icon_button(
|
||||
appearance,
|
||||
Icon::LogOut,
|
||||
false,
|
||||
self.log_out_icon_button_mouse_handle.clone(),
|
||||
)
|
||||
.with_tooltip(move || ui_builder.tool_tip("Log out".to_string()).build().finish())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(MCPServersEditPageViewAction::LogOut))
|
||||
.finish();
|
||||
|
||||
let mut rhs_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(style::PAGE_SPACING);
|
||||
if self.should_show_oauth_components(app) {
|
||||
rhs_row.add_child(log_out_icon_button);
|
||||
}
|
||||
if Self::is_editable(self.server_card_item_id, app) {
|
||||
rhs_row.add_child(
|
||||
Container::new(ChildView::new(&self.save_button).finish())
|
||||
.with_margin_left(style::EDIT_PAGE_BUTTON_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
} else if Self::is_reinstallable(self.server_card_item_id, app) {
|
||||
rhs_row.add_child(ChildView::new(&self.reinstall_button).finish());
|
||||
}
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(self.render_back_button(appearance))
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(title, true)
|
||||
.with_style(style::header_text())
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(rhs_row.finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_back_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let button = icon_button(appearance, Icon::ArrowLeft, false, self.back_button.clone());
|
||||
Container::new(
|
||||
button
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(MCPServersEditPageViewAction::Back);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(style::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn is_shared(item_id: ServerCardItemId, app: &AppContext) -> bool {
|
||||
match item_id {
|
||||
ServerCardItemId::TemplatableMCP(template_uuid) => {
|
||||
TemplatableMCPServerManager::as_ref(app)
|
||||
.is_server_template_shared(template_uuid, app)
|
||||
}
|
||||
ServerCardItemId::TemplatableMCPInstallation(installation_uuid) => {
|
||||
TemplatableMCPServerManager::as_ref(app)
|
||||
.is_server_installation_shared(installation_uuid, app)
|
||||
}
|
||||
ServerCardItemId::GalleryMCP(_) | ServerCardItemId::FileBasedMCP(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_editable(item_id: Option<ServerCardItemId>, app: &AppContext) -> bool {
|
||||
match item_id {
|
||||
Some(ServerCardItemId::TemplatableMCPInstallation(installation_uuid)) => {
|
||||
let template_uuid =
|
||||
TemplatableMCPServerManager::as_ref(app).get_template_uuid(installation_uuid);
|
||||
|
||||
if let Some(template_uuid) = template_uuid {
|
||||
let is_authorized_editor = TemplatableMCPServerManager::as_ref(app)
|
||||
.is_authorized_editor(template_uuid, app);
|
||||
let is_shared = TemplatableMCPServerManager::as_ref(app)
|
||||
.is_server_template_shared(template_uuid, app);
|
||||
|
||||
is_authorized_editor || !is_shared
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Some(ServerCardItemId::TemplatableMCP(template_uuid)) => {
|
||||
let is_shared = TemplatableMCPServerManager::as_ref(app)
|
||||
.is_server_template_shared(template_uuid, app);
|
||||
let is_authorized_editor = TemplatableMCPServerManager::as_ref(app)
|
||||
.is_authorized_editor(template_uuid, app);
|
||||
|
||||
is_authorized_editor || !is_shared
|
||||
}
|
||||
Some(ServerCardItemId::GalleryMCP(_)) | Some(ServerCardItemId::FileBasedMCP(_)) => {
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_reinstallable(item_id: Option<ServerCardItemId>, app: &AppContext) -> bool {
|
||||
if let Some(ServerCardItemId::TemplatableMCPInstallation(installation_uuid)) = item_id {
|
||||
let installation =
|
||||
TemplatableMCPServerManager::as_ref(app).get_installed_server(&installation_uuid);
|
||||
if let Some(installation) = installation {
|
||||
let has_variables = !installation
|
||||
.templatable_mcp_server()
|
||||
.template
|
||||
.variables
|
||||
.is_empty();
|
||||
return has_variables;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_deletable(item_id: ServerCardItemId, app: &AppContext) -> bool {
|
||||
Self::is_editable(Some(item_id), app)
|
||||
}
|
||||
|
||||
fn is_unshareable(item_id: ServerCardItemId, app: &AppContext) -> bool {
|
||||
let is_shared = Self::is_shared(item_id, app);
|
||||
let template_uuid = match item_id {
|
||||
ServerCardItemId::TemplatableMCP(template_uuid) => Some(template_uuid),
|
||||
ServerCardItemId::TemplatableMCPInstallation(installation_uuid) => {
|
||||
TemplatableMCPServerManager::as_ref(app).get_template_uuid(installation_uuid)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let is_author = template_uuid
|
||||
.map(|template_uuid| {
|
||||
TemplatableMCPServerManager::as_ref(app).is_author(template_uuid, app)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
is_author && is_shared
|
||||
}
|
||||
|
||||
fn render_editor(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.ui_font_size();
|
||||
let border_color = internal_colors::neutral_4(theme);
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Container::new(Text::new("JSON", ui_font_family, font_size).finish())
|
||||
.with_vertical_padding(10.)
|
||||
.with_horizontal_padding(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(border_color)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(ChildView::new(&self.json_editor).finish())
|
||||
.with_vertical_padding(style::EDITOR_VERTICAL_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_border(Border::all(1.).with_border_color(border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_footer(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let mut footer = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(style::EDIT_PAGE_BUTTON_SPACING);
|
||||
|
||||
if let Some(server_card_item_id) = self.server_card_item_id {
|
||||
if Self::is_deletable(server_card_item_id, app) {
|
||||
footer.add_child(ChildView::new(&self.delete_button).finish());
|
||||
}
|
||||
if Self::is_unshareable(server_card_item_id, app) {
|
||||
footer.add_child(ChildView::new(&self.unshare_button).finish());
|
||||
}
|
||||
}
|
||||
|
||||
footer.finish()
|
||||
}
|
||||
|
||||
fn detect_secrets_in_templatable_mcp_server(
|
||||
&self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
templatable_mcp_server: &TemplatableMCPServer,
|
||||
) -> Result<(), String> {
|
||||
let contains_secrets =
|
||||
!find_secrets_in_text(&templatable_mcp_server.template.json).is_empty();
|
||||
|
||||
if contains_secrets {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error("This MCP server contains secrets. Visit Settings > Privacy to modify your secret redaction settings.".to_string()),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
return Err("This MCP server contains secrets. Visit Settings > Privacy to modify your secret redaction settings.".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_templatable_json(
|
||||
&self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
json: &str,
|
||||
) -> Vec<ParsedTemplatableMCPServerResult> {
|
||||
let parsed_templatable_mcp_servers =
|
||||
match ParsedTemplatableMCPServerResult::from_user_json(json) {
|
||||
Ok(parsed_servers) => parsed_servers,
|
||||
Err(error) => {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error(error.to_string()),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
for parsed_templatable_mcp_server_result in parsed_templatable_mcp_servers.iter() {
|
||||
if self
|
||||
.detect_secrets_in_templatable_mcp_server(
|
||||
ctx,
|
||||
&parsed_templatable_mcp_server_result.templatable_mcp_server,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(Pei): Stop and start servers
|
||||
|
||||
parsed_templatable_mcp_servers
|
||||
}
|
||||
|
||||
fn build_templatable_mcp_server_result_from_json(
|
||||
&self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
json: &str,
|
||||
) -> Result<ParsedTemplatableMCPServerResult, String> {
|
||||
let parsed_templatable_mcp_servers = self.parse_templatable_json(ctx, json);
|
||||
|
||||
if parsed_templatable_mcp_servers.is_empty() {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error("No MCP Server specified.".to_string()),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
return Err("No MCP Server specified.".to_string());
|
||||
}
|
||||
|
||||
if parsed_templatable_mcp_servers.len() > 1 {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error(
|
||||
"Cannot add multiple MCP servers while editing a single server."
|
||||
.to_string(),
|
||||
),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
return Err(
|
||||
"Cannot add multiple MCP servers while editing a single server.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(parsed_templatable_mcp_servers[0].clone())
|
||||
}
|
||||
|
||||
fn handle_delete_confirmation_event(
|
||||
&mut self,
|
||||
event: &DestructiveMCPConfirmationDialogEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
DestructiveMCPConfirmationDialogEvent::Cancel => {
|
||||
self.destructive_mcp_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.hide(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
DestructiveMCPConfirmationDialogEvent::Confirm(variant) => {
|
||||
if let Some(server_card_item_id) = self.server_card_item_id {
|
||||
match variant {
|
||||
DestructiveMCPConfirmationDialogVariant::DeleteLocal
|
||||
| DestructiveMCPConfirmationDialogVariant::DeleteShared => {
|
||||
ctx.emit(MCPServersEditPageViewEvent::Delete(server_card_item_id));
|
||||
}
|
||||
DestructiveMCPConfirmationDialogVariant::Unshare => {
|
||||
match server_card_item_id {
|
||||
ServerCardItemId::TemplatableMCP(template_uuid) => {
|
||||
TemplatableMCPServerManager::handle(ctx).update(
|
||||
ctx,
|
||||
|templatable_manager, ctx| {
|
||||
templatable_manager
|
||||
.unshare_templatable_mcp_server(template_uuid, ctx);
|
||||
},
|
||||
);
|
||||
ctx.emit(MCPServersEditPageViewEvent::Back);
|
||||
}
|
||||
ServerCardItemId::TemplatableMCPInstallation(installation_uuid) => {
|
||||
TemplatableMCPServerManager::handle(ctx).update(
|
||||
ctx,
|
||||
|templatable_manager, ctx| {
|
||||
templatable_manager
|
||||
.unshare_templatable_mcp_server_installation(
|
||||
installation_uuid,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
);
|
||||
ctx.emit(MCPServersEditPageViewEvent::Back);
|
||||
}
|
||||
_ => {
|
||||
log::warn!(
|
||||
"This server is not an installation and cannot be unshared"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.destructive_mcp_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.hide(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_mcp_server_env_vars(mcp_server: MCPServer, ctx: &mut ViewContext<Self>) {
|
||||
if let TransportType::CLIServer(cli_server) = &mcp_server.transport_type {
|
||||
let env_vars: HashMap<String, String> = cli_server
|
||||
.static_env_vars
|
||||
.iter()
|
||||
.map(|env_var| (env_var.name.clone(), env_var.value.clone()))
|
||||
.collect();
|
||||
let Ok(env_vars_string) = serde_json::to_string(&env_vars) else {
|
||||
log::error!("Could not serialize MCP env vars");
|
||||
return;
|
||||
};
|
||||
let global_resource_handles = GlobalResourceHandlesProvider::as_ref(ctx).get().clone();
|
||||
|
||||
if let Some(model_event_sender) = &global_resource_handles.model_event_sender {
|
||||
if let Err(e) =
|
||||
model_event_sender.send(ModelEvent::UpsertMCPServerEnvironmentVariables {
|
||||
mcp_server_uuid: mcp_server.uuid.as_bytes().to_vec(),
|
||||
environment_variables: env_vars_string,
|
||||
})
|
||||
{
|
||||
log::error!("Error persisting MCP server env vars to database: {e:?}");
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_save_templatable_mcp_server(
|
||||
&mut self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
template_uuid: Uuid,
|
||||
) -> Result<(), String> {
|
||||
let json = self.json_editor.as_ref(ctx).text(ctx).into_string();
|
||||
let parsed_result = self.build_templatable_mcp_server_result_from_json(ctx, &json)?;
|
||||
|
||||
let original_template =
|
||||
TemplatableMCPServerManager::as_ref(ctx).get_templatable_mcp_server(template_uuid);
|
||||
let gallery_data = original_template.and_then(|template| template.gallery_data);
|
||||
|
||||
TemplatableMCPServerManager::handle(ctx).update(ctx, |templatable_manager, ctx| {
|
||||
let templatable_mcp_server = TemplatableMCPServer {
|
||||
uuid: template_uuid,
|
||||
name: parsed_result.templatable_mcp_server.name,
|
||||
description: parsed_result.templatable_mcp_server.description,
|
||||
template: parsed_result.templatable_mcp_server.template,
|
||||
version: parsed_result.templatable_mcp_server.version,
|
||||
gallery_data,
|
||||
};
|
||||
|
||||
if let Some(old_installation) =
|
||||
templatable_manager.get_installation_by_template_uuid(template_uuid)
|
||||
{
|
||||
templatable_manager
|
||||
.delete_templatable_mcp_server_installation(old_installation.uuid(), ctx);
|
||||
}
|
||||
|
||||
templatable_manager.update_templatable_mcp_server(templatable_mcp_server.clone(), ctx);
|
||||
|
||||
if let Some(new_installation) = parsed_result.templatable_mcp_server_installation {
|
||||
templatable_manager.install_from_template(
|
||||
templatable_mcp_server.clone(),
|
||||
new_installation.variable_values().clone(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for MCPServersEditPageView {
|
||||
type Event = MCPServersEditPageViewEvent;
|
||||
}
|
||||
|
||||
impl View for MCPServersEditPageView {
|
||||
fn ui_name() -> &'static str {
|
||||
"MCPServersEditPageView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let header = self.render_header(app);
|
||||
let editor = self.render_editor(app);
|
||||
let footer = self.render_footer(app);
|
||||
|
||||
let mut main_content = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(style::PAGE_SPACING);
|
||||
main_content.add_child(header);
|
||||
if !Self::is_editable(self.server_card_item_id, app) {
|
||||
main_content.add_child(ChildView::new(&self.editing_disabled_banner).finish());
|
||||
}
|
||||
main_content.add_child(Shrinkable::new(1., editor).finish());
|
||||
main_content.add_child(footer);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(Container::new(main_content.finish()).finish());
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&self.destructive_mcp_confirmation_dialog).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for MCPServersEditPageView {
|
||||
type Action = MCPServersEditPageViewAction;
|
||||
|
||||
fn handle_action(
|
||||
&mut self,
|
||||
action: &MCPServersEditPageViewAction,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match action {
|
||||
MCPServersEditPageViewAction::Back => {
|
||||
ctx.emit(MCPServersEditPageViewEvent::Back);
|
||||
}
|
||||
MCPServersEditPageViewAction::Delete => {
|
||||
let Some(server_card_item_id) = self.server_card_item_id else {
|
||||
return;
|
||||
};
|
||||
let is_shared = Self::is_shared(server_card_item_id, ctx);
|
||||
|
||||
let variant = if is_shared {
|
||||
DestructiveMCPConfirmationDialogVariant::DeleteShared
|
||||
} else {
|
||||
DestructiveMCPConfirmationDialogVariant::DeleteLocal
|
||||
};
|
||||
|
||||
self.destructive_mcp_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.show(variant, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
MCPServersEditPageViewAction::Unshare => {
|
||||
self.destructive_mcp_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.show(DestructiveMCPConfirmationDialogVariant::Unshare, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
MCPServersEditPageViewAction::Reinstall => {
|
||||
if let Some(ServerCardItemId::TemplatableMCPInstallation(uuid)) =
|
||||
self.server_card_item_id
|
||||
{
|
||||
ctx.emit(MCPServersEditPageViewEvent::Reinstall(uuid));
|
||||
}
|
||||
}
|
||||
MCPServersEditPageViewAction::Save => match self.server_card_item_id {
|
||||
Some(ServerCardItemId::TemplatableMCP(template_uuid)) => {
|
||||
let result = self.handle_save_templatable_mcp_server(ctx, template_uuid);
|
||||
if result.is_ok() {
|
||||
ctx.emit(MCPServersEditPageViewEvent::Back);
|
||||
}
|
||||
}
|
||||
Some(ServerCardItemId::TemplatableMCPInstallation(installation_uuid)) => {
|
||||
let template_uuid = TemplatableMCPServerManager::as_ref(ctx)
|
||||
.get_installed_server(&installation_uuid)
|
||||
.map(|installation| installation.template_uuid());
|
||||
|
||||
if let Some(template_uuid) = template_uuid {
|
||||
let result = self.handle_save_templatable_mcp_server(ctx, template_uuid);
|
||||
if result.is_ok() {
|
||||
ctx.emit(MCPServersEditPageViewEvent::Back);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ServerCardItemId::GalleryMCP(_uuid)) => {
|
||||
log::warn!("Editing of gallery MCP unimplemented");
|
||||
}
|
||||
Some(ServerCardItemId::FileBasedMCP(_)) => {
|
||||
log::warn!("Editing of file-based MCP unimplemented");
|
||||
}
|
||||
None => {
|
||||
// This is a new MCP server, we should treat it like a legacy MCP server
|
||||
let json = self.json_editor.as_ref(ctx).text(ctx).into_string();
|
||||
|
||||
let parsed_servers =
|
||||
match ParsedTemplatableMCPServerResult::from_user_json(&json) {
|
||||
Ok(parsed_templatable_mcp_servers) => parsed_templatable_mcp_servers,
|
||||
Err(error) => {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error(error.to_string()),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if parsed_servers.is_empty() {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error("No MCP Server specified.".to_string()),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for parsed_server in parsed_servers {
|
||||
TemplatableMCPServerManager::handle(ctx).update(
|
||||
ctx,
|
||||
|templatable_manager, ctx| {
|
||||
templatable_manager.create_templatable_mcp_server(
|
||||
parsed_server.templatable_mcp_server.clone(),
|
||||
Space::Personal,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
if let Some(installation) =
|
||||
parsed_server.templatable_mcp_server_installation
|
||||
{
|
||||
templatable_manager.install_from_template(
|
||||
installation.templatable_mcp_server().clone(),
|
||||
installation.variable_values().clone(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::MCPTemplateCreated {
|
||||
source: MCPTemplateCreationSource::Json,
|
||||
variables: parsed_server.templatable_mcp_server.template.variables,
|
||||
name: parsed_server.templatable_mcp_server.name,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
ctx.emit(MCPServersEditPageViewEvent::Back);
|
||||
}
|
||||
},
|
||||
MCPServersEditPageViewAction::LogOut => {
|
||||
if let Some(item_id) = self.server_card_item_id {
|
||||
ctx.emit(MCPServersEditPageViewEvent::LogOut(
|
||||
item_id,
|
||||
self.server_model.name(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::ai::mcp::templatable_installation::{VariableType, VariableValue};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::Event as EditorEvent;
|
||||
use crate::editor::{EditorView, SingleLineEditorOptions};
|
||||
use crate::settings_view::mcp_servers::style::{
|
||||
INSTALLATION_MODAL_BUTTON_GAP, INSTALLATION_MODAL_BUTTON_PADDING,
|
||||
INSTALLATION_MODAL_INPUT_VERTICAL_SPACING, INSTALLATION_MODAL_LABEL_VERTICAL_SPACING,
|
||||
INSTALLATION_MODAL_PADDING, INSTALLATION_MODAL_TITLE_VERTICAL_SPACING,
|
||||
};
|
||||
use crate::view_components::dropdown::{Dropdown, DropdownItem};
|
||||
use markdown_parser::parse_markdown;
|
||||
use warpui::elements::Shrinkable;
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex,
|
||||
FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment, MouseStateHandle,
|
||||
ParentElement, Text,
|
||||
},
|
||||
platform::Cursor,
|
||||
AppContext, Element, Entity, FocusContext, TypedActionView, View, ViewHandle,
|
||||
};
|
||||
use warpui::{SingletonEntity, ViewContext};
|
||||
|
||||
use crate::ai::mcp::{TemplatableMCPServer, TemplatableMCPServerManager, TemplateVariable};
|
||||
|
||||
use crate::ui_components::{
|
||||
avatar::{Avatar, AvatarContent},
|
||||
blended_colors,
|
||||
};
|
||||
use warpui::elements::{CornerRadius, Padding, Radius};
|
||||
|
||||
use warp_core::ui::{
|
||||
color::coloru_with_opacity, external_product_icon::ExternalProductIcon, icons::Icon,
|
||||
};
|
||||
|
||||
pub enum InstallationModalBodyEvent {
|
||||
Cancel,
|
||||
Install(TemplatableMCPServer, HashMap<String, VariableValue>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DropdownValueSelection {
|
||||
pub variable_key: String,
|
||||
pub selected_value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum InstallationModalBodyAction {
|
||||
Cancel,
|
||||
Install,
|
||||
SelectDropdownValue(DropdownValueSelection),
|
||||
}
|
||||
|
||||
/// Represents the input widget for a single template variable.
|
||||
enum VariableInput {
|
||||
/// A freetext editor for variables without predefined values.
|
||||
TextInput(ViewHandle<EditorView>),
|
||||
/// A dropdown selector for variables with predefined allowed values.
|
||||
Dropdown {
|
||||
handle: ViewHandle<Dropdown<InstallationModalBodyAction>>,
|
||||
selected_value: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct InstallationModalBody {
|
||||
templatable_mcp_server: Option<TemplatableMCPServer>,
|
||||
instructions_in_markdown: Option<String>,
|
||||
variable_inputs: HashMap<String, VariableInput>,
|
||||
cancel_mouse_state: MouseStateHandle,
|
||||
install_mouse_state: MouseStateHandle,
|
||||
close_button_mouse_state: MouseStateHandle,
|
||||
is_shared: bool,
|
||||
}
|
||||
|
||||
impl Default for InstallationModalBody {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InstallationModalBody {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
templatable_mcp_server: None,
|
||||
instructions_in_markdown: None,
|
||||
variable_inputs: HashMap::new(),
|
||||
cancel_mouse_state: Default::default(),
|
||||
install_mouse_state: Default::default(),
|
||||
close_button_mouse_state: Default::default(),
|
||||
is_shared: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_templatable_mcp_server(
|
||||
&mut self,
|
||||
templatable_mcp_server: Option<TemplatableMCPServer>,
|
||||
instructions_in_markdown: Option<String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.templatable_mcp_server = templatable_mcp_server.clone();
|
||||
self.instructions_in_markdown = instructions_in_markdown;
|
||||
|
||||
if let Some(templatable_mcp_server) = &self.templatable_mcp_server {
|
||||
self.is_shared = TemplatableMCPServerManager::as_ref(ctx)
|
||||
.is_server_template_shared(templatable_mcp_server.uuid, ctx);
|
||||
|
||||
self.variable_inputs = templatable_mcp_server
|
||||
.template
|
||||
.variables
|
||||
.iter()
|
||||
.map(|variable| {
|
||||
let key = variable.key.clone();
|
||||
let allowed_values = variable.allowed_values.clone().unwrap_or_default();
|
||||
|
||||
let input = if !allowed_values.is_empty() {
|
||||
let variable_key = key.clone();
|
||||
let dropdown_handle = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = Dropdown::new(ctx);
|
||||
let items: Vec<DropdownItem<InstallationModalBodyAction>> =
|
||||
allowed_values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
DropdownItem::new(
|
||||
value.clone(),
|
||||
InstallationModalBodyAction::SelectDropdownValue(
|
||||
DropdownValueSelection {
|
||||
variable_key: variable_key.clone(),
|
||||
selected_value: value.clone(),
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
dropdown.set_items(items, ctx);
|
||||
dropdown.set_selected_by_index(0, ctx);
|
||||
dropdown
|
||||
});
|
||||
|
||||
// Initial value can never be None since we know the list is not empty
|
||||
let initial_value = allowed_values.first().cloned();
|
||||
VariableInput::Dropdown {
|
||||
handle: dropdown_handle,
|
||||
selected_value: initial_value,
|
||||
}
|
||||
} else {
|
||||
let editor = ctx.add_view(|ctx| {
|
||||
EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
soft_wrap: true,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
ctx.subscribe_to_view(&editor, Self::handle_editor_event);
|
||||
VariableInput::TextInput(editor)
|
||||
};
|
||||
(key, input)
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
self.variable_inputs = HashMap::new();
|
||||
self.is_shared = false;
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_editor_event(
|
||||
&mut self,
|
||||
_handle: ViewHandle<EditorView>,
|
||||
event: &EditorEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Forwards escape key press from an input editor to its parent modal
|
||||
if matches!(event, EditorEvent::Escape) {
|
||||
ctx.emit(InstallationModalBodyEvent::Cancel);
|
||||
}
|
||||
// Forwards enter key press from an input editor to trigger installation
|
||||
else if matches!(event, EditorEvent::Enter) {
|
||||
self.process_installation(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_installation(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(templatable_mcp_server) = &self.templatable_mcp_server {
|
||||
let variable_values = templatable_mcp_server
|
||||
.template
|
||||
.variables
|
||||
.iter()
|
||||
.filter_map(|variable| {
|
||||
let input = self.variable_inputs.get(&variable.key)?;
|
||||
let value = match input {
|
||||
VariableInput::TextInput(editor) => editor.as_ref(ctx).buffer_text(ctx),
|
||||
VariableInput::Dropdown { selected_value, .. } => {
|
||||
selected_value.clone().unwrap_or_default()
|
||||
}
|
||||
};
|
||||
Some((
|
||||
variable.key.clone(),
|
||||
VariableValue {
|
||||
variable_type: VariableType::Text,
|
||||
value,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
ctx.emit(InstallationModalBodyEvent::Install(
|
||||
templatable_mcp_server.clone(),
|
||||
variable_values,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_title(
|
||||
name: String,
|
||||
appearance: &Appearance,
|
||||
close_button_mouse_state: MouseStateHandle,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Renders MCP avatar icon
|
||||
let avatar_content = if let Some(icon) = ExternalProductIcon::from_string(name.as_str()) {
|
||||
AvatarContent::ExternalProductIcon(icon)
|
||||
} else {
|
||||
AvatarContent::DisplayName(name.clone())
|
||||
};
|
||||
let 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()
|
||||
},
|
||||
)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
// Renders MCP title text
|
||||
let title = Text::new(
|
||||
format!("Install {name}"),
|
||||
appearance.ui_font_family(),
|
||||
appearance.header_font_size(),
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
// Renders 'X' icon for closing the modal
|
||||
let escape_icon = Shrinkable::new(
|
||||
1.,
|
||||
Align::new(
|
||||
Hoverable::new(close_button_mouse_state, |state| {
|
||||
let mut icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::X
|
||||
.to_warpui_icon(theme.active_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_padding(Padding::uniform(2.));
|
||||
if state.is_hovered() {
|
||||
icon = icon.with_background(appearance.theme().surface_2());
|
||||
}
|
||||
icon.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(InstallationModalBodyAction::Cancel)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Renders 'ESC' text for closing the modal
|
||||
let escape_button = Container::new(
|
||||
Text::new_inline(
|
||||
"ESC".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size() * 0.8,
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(theme.surface_2().into())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_padding(Padding::uniform(4.))
|
||||
.finish();
|
||||
|
||||
// Renders title row
|
||||
let title_row = Flex::row()
|
||||
.with_children(vec![avatar, title, escape_icon, escape_button])
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_spacing(8.)
|
||||
.finish();
|
||||
|
||||
Container::new(title_row)
|
||||
.with_margin_bottom(INSTALLATION_MODAL_TITLE_VERTICAL_SPACING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_markdown_instructions(
|
||||
markdown_instructions: &str,
|
||||
appearance: &Appearance,
|
||||
) -> Result<Box<dyn Element>, String> {
|
||||
let theme = appearance.theme();
|
||||
match parse_markdown(markdown_instructions) {
|
||||
Ok(formatted_text) => Ok(Container::new(
|
||||
FormattedTextElement::new(
|
||||
formatted_text,
|
||||
appearance.ui_font_size(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
theme.active_ui_text_color().into(),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.with_hyperlink_font_color(appearance.theme().accent().into_solid())
|
||||
.register_default_click_handlers(|url, _, ctx| {
|
||||
ctx.open_url(&url.url);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(INSTALLATION_MODAL_TITLE_VERTICAL_SPACING)
|
||||
.finish()),
|
||||
Err(e) => Err(format!("Failed to parse markdown: {e:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_input_fields(
|
||||
&self,
|
||||
mut form_column: Flex,
|
||||
variables: Vec<TemplateVariable>,
|
||||
appearance: &Appearance,
|
||||
) -> Flex {
|
||||
let theme = appearance.theme();
|
||||
for template_variable in &variables {
|
||||
// Label
|
||||
form_column.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
template_variable.key.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(INSTALLATION_MODAL_LABEL_VERTICAL_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Input field: dropdown for allowed_values, text input otherwise
|
||||
if let Some(variable_input) = self.variable_inputs.get(&template_variable.key) {
|
||||
match variable_input {
|
||||
VariableInput::TextInput(editor) => {
|
||||
form_column.add_child(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(editor.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(INSTALLATION_MODAL_BUTTON_PADDING),
|
||||
background: Some(
|
||||
blended_colors::neutral_2(appearance.theme()).into(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(INSTALLATION_MODAL_INPUT_VERTICAL_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
VariableInput::Dropdown { handle, .. } => {
|
||||
form_column.add_child(
|
||||
Container::new(ChildView::new(handle).finish())
|
||||
.with_margin_bottom(INSTALLATION_MODAL_INPUT_VERTICAL_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
form_column
|
||||
}
|
||||
|
||||
fn render_source_indicator(is_shared: bool, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let info_icon = ConstrainedBox::new(
|
||||
Icon::Info
|
||||
.to_warpui_icon(appearance.theme().disabled_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
|
||||
let source_text = if is_shared {
|
||||
"Shared from team"
|
||||
} else {
|
||||
"From another device"
|
||||
};
|
||||
|
||||
let label_text = Text::new_inline(
|
||||
source_text.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().disabled_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(info_icon)
|
||||
.with_child(label_text)
|
||||
.with_spacing(INSTALLATION_MODAL_BUTTON_GAP)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_action_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let cancel_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Text, self.cancel_mouse_state.clone())
|
||||
.with_text_label("Cancel".into())
|
||||
.with_style(UiComponentStyles {
|
||||
font_weight: Some(Weight::Bold),
|
||||
font_color: Some(appearance.theme().active_ui_text_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_hovered_styles(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().disabled_ui_text_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(InstallationModalBodyAction::Cancel))
|
||||
.finish();
|
||||
|
||||
let corner_down_left_icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::CornerDownLeft
|
||||
.to_warpui_icon(appearance.theme().active_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(2.)
|
||||
.with_border(Border::all(1.).with_border_fill(coloru_with_opacity(
|
||||
appearance.theme().active_ui_text_color().into(),
|
||||
60,
|
||||
)))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish();
|
||||
|
||||
let install_button_label = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
"Install",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(corner_down_left_icon)
|
||||
.with_margin_left(8.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let install_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.install_mouse_state.clone())
|
||||
.with_custom_label(install_button_label)
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(Coords::uniform(5.).left(10.).right(10.)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(InstallationModalBodyAction::Install))
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(cancel_button)
|
||||
.with_margin_right(INSTALLATION_MODAL_BUTTON_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Container::new(install_button).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_buttons_row(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let source_indicator = Self::render_source_indicator(self.is_shared, appearance);
|
||||
let action_buttons = self.render_action_buttons(appearance);
|
||||
|
||||
let spacer = Shrinkable::new(1., Container::new(Empty::new().finish()).finish()).finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_child(source_indicator)
|
||||
.with_child(spacer)
|
||||
.with_child(action_buttons)
|
||||
.finish();
|
||||
|
||||
Container::new(row)
|
||||
.with_border(Border::top(1.).with_border_fill(appearance.theme().outline()))
|
||||
.with_uniform_padding(INSTALLATION_MODAL_PADDING)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InstallationModalBody {
|
||||
type Event = InstallationModalBodyEvent;
|
||||
}
|
||||
|
||||
impl View for InstallationModalBody {
|
||||
fn ui_name() -> &'static str {
|
||||
"MCPTemplateInstallationModalBody"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
// Focus the first text input editor, if any.
|
||||
// Iterate in template variable order to focus the first one.
|
||||
if let Some(server) = &self.templatable_mcp_server {
|
||||
for variable in &server.template.variables {
|
||||
match self.variable_inputs.get(&variable.key) {
|
||||
Some(VariableInput::TextInput(editor)) => {
|
||||
ctx.focus(editor);
|
||||
return;
|
||||
}
|
||||
Some(VariableInput::Dropdown { handle, .. }) => {
|
||||
ctx.focus(handle);
|
||||
return;
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, ctx: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
if let Some(templatable_mcp_server) = &self.templatable_mcp_server {
|
||||
let mut form_column =
|
||||
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
form_column.add_child(Self::render_title(
|
||||
templatable_mcp_server.name.clone(),
|
||||
appearance,
|
||||
self.close_button_mouse_state.clone(),
|
||||
));
|
||||
|
||||
if let Some(instructions) = &self.instructions_in_markdown {
|
||||
if !instructions.is_empty() {
|
||||
let instructions_result =
|
||||
Self::render_markdown_instructions(instructions, appearance);
|
||||
if let Ok(rendered_instructions) = instructions_result {
|
||||
form_column.add_child(rendered_instructions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form_column = self.render_input_fields(
|
||||
form_column,
|
||||
templatable_mcp_server.template.variables.clone(),
|
||||
appearance,
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(
|
||||
Container::new(form_column.finish())
|
||||
.with_uniform_padding(INSTALLATION_MODAL_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_buttons_row(appearance))
|
||||
.finish()
|
||||
} else {
|
||||
Text::new(
|
||||
"No MCP server selected",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for InstallationModalBody {
|
||||
type Action = InstallationModalBodyAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
InstallationModalBodyAction::Cancel => ctx.emit(InstallationModalBodyEvent::Cancel),
|
||||
InstallationModalBodyAction::Install => self.process_installation(ctx),
|
||||
InstallationModalBodyAction::SelectDropdownValue(selection) => {
|
||||
if let Some(VariableInput::Dropdown { selected_value, .. }) =
|
||||
self.variable_inputs.get_mut(&selection.variable_key)
|
||||
{
|
||||
*selected_value = Some(selection.selected_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
fmt::{Display, Formatter, Result},
|
||||
};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::server::ids::ObjectUid;
|
||||
|
||||
pub mod destructive_mcp_confirmation_dialog;
|
||||
pub mod edit_page;
|
||||
pub mod installation_modal;
|
||||
pub mod list_page;
|
||||
pub mod server_card;
|
||||
pub mod style;
|
||||
pub mod update_modal;
|
||||
|
||||
// TODO(aeybel/pei): In the future, to enable the re-use of ServerCard for different types of servers (eg. MCP, LSP, etc.)
|
||||
// We should make ServerCardView and its corresponding events and actions generic
|
||||
// And define different types of server card ids (eg. MCPId, LSPId) that can be used with this generic card
|
||||
// As an example of what this might look like: https://github.com/warpdotdev/warp-internal/pull/19291/files
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ServerCardItemId {
|
||||
TemplatableMCP(Uuid),
|
||||
TemplatableMCPInstallation(Uuid),
|
||||
GalleryMCP(Uuid),
|
||||
FileBasedMCP(Uuid),
|
||||
}
|
||||
|
||||
impl Ord for ServerCardItemId {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
let self_id = self.to_string();
|
||||
let other_id = other.to_string();
|
||||
self_id.cmp(&other_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for ServerCardItemId {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ServerCardItemId {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
|
||||
match self {
|
||||
ServerCardItemId::TemplatableMCP(template_uuid) => {
|
||||
write!(f, "Templatable MCP Id: {template_uuid}")
|
||||
}
|
||||
ServerCardItemId::TemplatableMCPInstallation(uuid) => {
|
||||
write!(f, "Templatable MCP Installation Id: {uuid}")
|
||||
}
|
||||
ServerCardItemId::GalleryMCP(uuid) => write!(f, "Gallery MCP Id: {uuid}"),
|
||||
ServerCardItemId::FileBasedMCP(uuid) => write!(f, "File-Based MCP Id: {uuid}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerCardItemId {
|
||||
pub fn uid(&self) -> ObjectUid {
|
||||
match self {
|
||||
ServerCardItemId::TemplatableMCP(template_uuid) => template_uuid.to_string(),
|
||||
ServerCardItemId::TemplatableMCPInstallation(uuid) => uuid.to_string(),
|
||||
ServerCardItemId::GalleryMCP(uuid) => uuid.to_string(),
|
||||
ServerCardItemId::FileBasedMCP(uuid) => uuid.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
fonts::Weight,
|
||||
ui_components::components::{Coords, UiComponentStyles},
|
||||
};
|
||||
|
||||
pub const ICON_MARGIN: f32 = 8.;
|
||||
pub const HEADER_FONT_SIZE: f32 = 18.;
|
||||
pub const CONTENT_FONT_SIZE: f32 = 12.;
|
||||
pub const PAGE_SPACING: f32 = 16.;
|
||||
pub const PAGE_PADDING: f32 = 28.;
|
||||
pub const ITEM_BOTTOM_MARGIN: f32 = 12.;
|
||||
pub const EDITOR_VERTICAL_PADDING: f32 = 10.;
|
||||
pub const INSTALLATION_MODAL_PADDING: f32 = 16.;
|
||||
pub const INSTALLATION_MODAL_BUTTON_GAP: f32 = 12.;
|
||||
pub const INSTALLATION_MODAL_BUTTON_TOP_MARGIN: f32 = 16.;
|
||||
pub const INSTALLATION_MODAL_INPUT_VERTICAL_SPACING: f32 = 12.;
|
||||
pub const INSTALLATION_MODAL_BUTTON_PADDING: Coords = Coords {
|
||||
left: 8.,
|
||||
right: 8.,
|
||||
top: 6.,
|
||||
bottom: 6.,
|
||||
};
|
||||
pub const INSTALLATION_MODAL_LABEL_VERTICAL_SPACING: f32 = 4.;
|
||||
pub const INSTALLATION_MODAL_TITLE_VERTICAL_SPACING: f32 = 16.;
|
||||
pub const SECTION_MARGIN: f32 = 16.;
|
||||
pub const EMPTY_STATE_HEIGHT: f32 = 400.;
|
||||
pub const TEXT_FONT_SIZE: f32 = 14.;
|
||||
pub const TITLE_CHIP_FONT_SIZE: f32 = 10.;
|
||||
pub const CORNER_RADIUS: f32 = 4.;
|
||||
pub const SERVER_CARD_LIST_SPACING: f32 = 8.;
|
||||
pub const SERVER_CARD_INTERIOR_SPACING: f32 = 4.;
|
||||
pub const SERVER_CARD_ACTIONS_STANDARD_WIDTH: f32 = 180.;
|
||||
pub const SERVER_CARD_ACTIONS_WIDE_WIDTH: f32 = 240.;
|
||||
pub const EDIT_PAGE_BUTTON_SPACING: f32 = 4.;
|
||||
pub const UPDATE_AVAILABLE_DOT_WIDTH: f32 = 6.;
|
||||
pub const TOOL_CHIP_TEXT_SIZE: f32 = 12.;
|
||||
|
||||
pub fn header_text() -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_size: Some(HEADER_FONT_SIZE),
|
||||
font_weight: Some(Weight::Bold),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description_text(appearance: &Appearance) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_size: Some(TEXT_FONT_SIZE),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
margin: Some(Coords {
|
||||
bottom: 8.,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
use crate::ai::mcp::{Author, MCPServerUpdate};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings_view::mcp_servers::style::{
|
||||
INSTALLATION_MODAL_BUTTON_GAP, INSTALLATION_MODAL_PADDING,
|
||||
};
|
||||
use crate::ui_components::avatar::{Avatar, AvatarContent};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::util::time_format::format_approx_duration_from_now;
|
||||
use chrono::{Local, TimeZone};
|
||||
use uuid::Uuid;
|
||||
use warp_core::ui::color::coloru_with_opacity;
|
||||
use warp_core::ui::external_product_icon::ExternalProductIcon;
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warpui::elements::{Align, Empty, Padding, Shrinkable};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
|
||||
MainAxisAlignment, MouseStateHandle, ParentElement, Radius, Text,
|
||||
},
|
||||
platform::Cursor,
|
||||
AppContext, Element, Entity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
pub enum UpdateModalBodyEvent {
|
||||
Cancel,
|
||||
Update {
|
||||
installation_uuid: Option<Uuid>,
|
||||
update: MCPServerUpdate,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UpdateModalBodyAction {
|
||||
Cancel,
|
||||
Update,
|
||||
SelectOption(usize),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UpdateModalBody {
|
||||
installation_uuid: Option<Uuid>,
|
||||
server_name: Option<String>,
|
||||
update_options: Vec<MCPServerUpdate>,
|
||||
selected_updates: Vec<bool>,
|
||||
cancel_mouse_state: MouseStateHandle,
|
||||
update_mouse_state: MouseStateHandle,
|
||||
close_button_mouse_state: MouseStateHandle,
|
||||
option_mouse_states: Vec<MouseStateHandle>,
|
||||
}
|
||||
|
||||
impl UpdateModalBody {
|
||||
pub fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn set_installation(
|
||||
&mut self,
|
||||
installation_uuid: Uuid,
|
||||
server_name: String,
|
||||
update_options: Vec<MCPServerUpdate>,
|
||||
) {
|
||||
self.installation_uuid = Some(installation_uuid);
|
||||
self.server_name = Some(server_name);
|
||||
self.update_options = update_options;
|
||||
self.selected_updates = vec![false; self.update_options.len()];
|
||||
self.option_mouse_states = (0..self.update_options.len())
|
||||
.map(|_| MouseStateHandle::default())
|
||||
.collect();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.installation_uuid = None;
|
||||
self.server_name = None;
|
||||
self.update_options = vec![];
|
||||
self.selected_updates = vec![];
|
||||
self.option_mouse_states = vec![];
|
||||
}
|
||||
|
||||
fn render_title(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let name = self.server_name.as_deref().unwrap_or("Server");
|
||||
|
||||
// Renders MCP avatar icon
|
||||
let avatar_content = if let Some(icon) = ExternalProductIcon::from_string(name) {
|
||||
AvatarContent::ExternalProductIcon(icon)
|
||||
} else {
|
||||
AvatarContent::DisplayName(name.to_string())
|
||||
};
|
||||
let 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()
|
||||
},
|
||||
)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
// Renders MCP title text
|
||||
let title = Text::new(
|
||||
format!("Update {name}"),
|
||||
appearance.ui_font_family(),
|
||||
appearance.header_font_size(),
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
// Renders 'X' icon for closing the modal
|
||||
let escape_icon = Shrinkable::new(
|
||||
1.,
|
||||
Align::new(
|
||||
Hoverable::new(self.close_button_mouse_state.clone(), |state| {
|
||||
let mut icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::X
|
||||
.to_warpui_icon(theme.active_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_padding(Padding::uniform(2.));
|
||||
if state.is_hovered() {
|
||||
icon = icon.with_background(appearance.theme().surface_2());
|
||||
}
|
||||
icon.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(UpdateModalBodyAction::Cancel))
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Renders 'ESC' text for closing the modal
|
||||
let escape_button = Container::new(
|
||||
Text::new_inline(
|
||||
"ESC".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size() * 0.8,
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(theme.surface_2().into())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_padding(Padding::uniform(4.))
|
||||
.finish();
|
||||
|
||||
// Renders title row
|
||||
let title_row = Flex::row()
|
||||
.with_children(vec![avatar, title, escape_icon, escape_button])
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_spacing(8.)
|
||||
.finish();
|
||||
|
||||
Container::new(title_row).with_margin_bottom(2.).finish()
|
||||
}
|
||||
|
||||
fn render_description(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
// Modal appears only when multiple updates are available
|
||||
let description = format!(
|
||||
"This server has {} updates available, which would you like to proceed with?",
|
||||
self.update_options.len()
|
||||
);
|
||||
|
||||
Text::new(
|
||||
description,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_update_option(
|
||||
&self,
|
||||
index: usize,
|
||||
option: &MCPServerUpdate,
|
||||
is_selected: bool,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(MouseStateHandle::default(), None)
|
||||
.check(is_selected)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let (title, description) = match option {
|
||||
MCPServerUpdate::CloudTemplate {
|
||||
publisher,
|
||||
new_version_ts,
|
||||
..
|
||||
} => {
|
||||
let publisher_string = match publisher {
|
||||
Author::CurrentUser => "another device",
|
||||
Author::OtherUser { name } => name,
|
||||
Author::Unknown => "a team member",
|
||||
};
|
||||
let datetime = Local
|
||||
.timestamp_opt(*new_version_ts, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Local::now);
|
||||
let formatted_time = format_approx_duration_from_now(datetime);
|
||||
(
|
||||
format!("Update from {publisher_string}"),
|
||||
formatted_time.to_string(),
|
||||
)
|
||||
}
|
||||
MCPServerUpdate::Gallery {
|
||||
name, new_version, ..
|
||||
} => (
|
||||
format!("Update from {name}"),
|
||||
format!("Version {new_version}"),
|
||||
),
|
||||
};
|
||||
|
||||
let content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(
|
||||
Text::new(
|
||||
title.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Text::new(
|
||||
description.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size() * 0.85,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(theme, theme.surface_2()))
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(12.)
|
||||
.with_child(Container::new(checkbox).with_margin_top(-4.).finish())
|
||||
.with_child(content)
|
||||
.finish();
|
||||
|
||||
let background_color = if is_selected {
|
||||
theme.accent().with_opacity(5)
|
||||
} else {
|
||||
blended_colors::neutral_2(theme).into()
|
||||
};
|
||||
|
||||
let border_color = if is_selected {
|
||||
theme.accent().into()
|
||||
} else {
|
||||
internal_colors::neutral_4(theme)
|
||||
};
|
||||
|
||||
let option_container = Container::new(row)
|
||||
.with_uniform_padding(12.)
|
||||
.with_background(background_color)
|
||||
.with_border(Border::all(1.).with_border_color(border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish();
|
||||
|
||||
Hoverable::new(self.option_mouse_states[index].clone(), |_| {
|
||||
option_container
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(UpdateModalBodyAction::SelectOption(index));
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_action_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let cancel_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Text, self.cancel_mouse_state.clone())
|
||||
.with_text_label("Cancel".into())
|
||||
.with_style(UiComponentStyles {
|
||||
font_weight: Some(Weight::Bold),
|
||||
font_color: Some(appearance.theme().active_ui_text_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_hovered_styles(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().disabled_ui_text_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(UpdateModalBodyAction::Cancel))
|
||||
.finish();
|
||||
|
||||
let corner_down_left_icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::CornerDownLeft
|
||||
.to_warpui_icon(appearance.theme().active_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(2.)
|
||||
.with_border(Border::all(1.).with_border_fill(coloru_with_opacity(
|
||||
appearance.theme().active_ui_text_color().into(),
|
||||
60,
|
||||
)))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish();
|
||||
|
||||
let update_button_label = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
"Update",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(corner_down_left_icon)
|
||||
.with_margin_left(8.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut update_button_builder = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.update_mouse_state.clone())
|
||||
.with_custom_label(update_button_label)
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(Coords::uniform(5.).left(10.).right(10.)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Disable the update button if no updates are selected
|
||||
let has_selection = self.selected_updates.iter().any(|&x| x);
|
||||
|
||||
if !has_selection {
|
||||
update_button_builder = update_button_builder.disabled();
|
||||
}
|
||||
|
||||
let update_button = update_button_builder
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(UpdateModalBodyAction::Update))
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(cancel_button)
|
||||
.with_margin_right(INSTALLATION_MODAL_BUTTON_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Container::new(update_button).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_buttons_row(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let action_buttons = self.render_action_buttons(appearance);
|
||||
|
||||
let spacer = Shrinkable::new(1., Container::new(Empty::new().finish()).finish()).finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_child(spacer)
|
||||
.with_child(action_buttons)
|
||||
.finish();
|
||||
|
||||
Container::new(row)
|
||||
.with_border(Border::top(1.).with_border_fill(appearance.theme().outline()))
|
||||
.with_uniform_padding(INSTALLATION_MODAL_PADDING)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for UpdateModalBody {
|
||||
type Event = UpdateModalBodyEvent;
|
||||
}
|
||||
|
||||
impl View for UpdateModalBody {
|
||||
fn ui_name() -> &'static str {
|
||||
"UpdateModalBody"
|
||||
}
|
||||
|
||||
fn render(&self, ctx: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
let mut content_column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(16.);
|
||||
|
||||
content_column.add_child(self.render_title(appearance));
|
||||
content_column.add_child(self.render_description(appearance));
|
||||
|
||||
// Add update options
|
||||
if self.update_options.is_empty() {
|
||||
let no_updates_text = Text::new(
|
||||
"No updates available",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.finish();
|
||||
content_column.add_child(no_updates_text);
|
||||
} else {
|
||||
for (index, option) in self.update_options.iter().enumerate() {
|
||||
let is_selected = self.selected_updates.get(index).copied().unwrap_or(false);
|
||||
content_column.add_child(self.render_update_option(
|
||||
index,
|
||||
option,
|
||||
is_selected,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(
|
||||
Container::new(content_column.finish())
|
||||
.with_uniform_padding(INSTALLATION_MODAL_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_buttons_row(appearance))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for UpdateModalBody {
|
||||
type Action = UpdateModalBodyAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
UpdateModalBodyAction::Cancel => ctx.emit(UpdateModalBodyEvent::Cancel),
|
||||
UpdateModalBodyAction::Update => {
|
||||
// Collect all selected updates and emit events for each
|
||||
for (index, &is_selected) in self.selected_updates.iter().enumerate() {
|
||||
if is_selected {
|
||||
ctx.emit(UpdateModalBodyEvent::Update {
|
||||
installation_uuid: self.installation_uuid,
|
||||
update: self.update_options[index].clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
UpdateModalBodyAction::SelectOption(index) => {
|
||||
// Toggle the selection at the given index
|
||||
if let Some(selected) = self.selected_updates.get_mut(*index) {
|
||||
*selected = !*selected;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user