Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -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
+68
View File
@@ -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();
}
}
}
}
}