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
+158
View File
@@ -0,0 +1,158 @@
use super::{
settings_page::{
MatchData, PageType, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget,
},
SettingsSection,
};
use crate::{
appearance::Appearance, channel::ChannelState, themes::theme::ColorScheme,
workspace::WorkspaceAction,
};
use warpui::{
assets::asset_cache::AssetSource,
elements::{
Align, CacheOption, ConstrainedBox, Container, CrossAxisAlignment, Element, Flex, Image,
MainAxisAlignment, MouseStateHandle, ParentElement, Wrap,
},
ui_components::components::UiComponent,
AppContext, Entity, View, ViewContext, ViewHandle,
};
pub struct AboutPageView {
page: PageType<Self>,
}
impl AboutPageView {
pub fn new(_ctx: &mut ViewContext<AboutPageView>) -> Self {
AboutPageView {
page: PageType::new_monolith(AboutPageWidget::default(), None, false),
}
}
}
impl Entity for AboutPageView {
type Event = SettingsPageEvent;
}
impl View for AboutPageView {
fn ui_name() -> &'static str {
"AboutPage"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
}
}
#[derive(Default)]
struct AboutPageWidget {
copy_version_button_mouse_state: MouseStateHandle,
}
impl SettingsWidget for AboutPageWidget {
type View = AboutPageView;
fn search_terms(&self) -> &str {
"about warp version"
}
fn render(
&self,
_view: &AboutPageView,
appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let ui_builder = appearance.ui_builder();
let image_path = if theme.inferred_color_scheme() == ColorScheme::LightOnDark {
"bundled/svg/warp-logo-with-light-title.svg"
} else {
"bundled/svg/warp-logo-with-dark-title.svg"
};
let version = ChannelState::app_version().unwrap_or("v#.##.###");
let version_text = ui_builder
.span(version.to_string())
.with_soft_wrap()
.build()
.with_margin_top(16.)
.finish();
let copy_version_icon = appearance
.ui_builder()
.copy_button(16., self.copy_version_button_mouse_state.clone())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::CopyVersion(version));
})
.finish();
let version_row = Wrap::row()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_children([
version_text,
Container::new(copy_version_icon)
.with_margin_top(16.)
.with_padding_left(6.)
.finish(),
]);
Align::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ConstrainedBox::new(
Image::new(
AssetSource::Bundled { path: image_path },
CacheOption::BySize,
)
.finish(),
)
.with_max_height(100.)
.with_max_width(350.)
.finish(),
)
.with_child(version_row.finish())
.with_child(
ui_builder
.span("Copyright 2026 Warp")
.build()
.with_margin_top(16.)
.finish(),
)
.finish(),
)
.finish()
}
}
impl SettingsPageMeta for AboutPageView {
fn section() -> SettingsSection {
SettingsSection::About
}
fn should_render(&self, _ctx: &AppContext) -> bool {
true
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
self.page.update_filter(query, ctx)
}
fn scroll_to_widget(&mut self, widget_id: &'static str) {
self.page.scroll_to_widget(widget_id)
}
fn clear_highlighted_widget(&mut self) {
self.page.clear_highlighted_widget();
}
}
impl From<ViewHandle<AboutPageView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<AboutPageView>) -> Self {
SettingsPageViewHandle::About(view_handle)
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::{channel::ChannelState, server::ids::ServerId};
use warpui::AppContext;
/// Shared admin panel actions and utilities for settings views
pub struct AdminActions;
impl AdminActions {
/// Generate the admin panel URL for a given team
pub fn admin_panel_link_for_team(team_uid: ServerId) -> String {
format!("{}/admin/{}", ChannelState::server_root_url(), team_uid)
}
/// Open the admin panel for a specific team
pub fn open_admin_panel(team_uid: ServerId, ctx: &mut AppContext) {
let url = Self::admin_panel_link_for_team(team_uid);
ctx.open_url(&url);
}
/// Open the support email link
pub fn contact_support(ctx: &mut AppContext) {
ctx.open_url("mailto:support@warp.dev");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_admin_panel_link_generation() {
let team_uid = ServerId::from(12345);
let expected_link = format!("{}/admin/{}", ChannelState::server_root_url(), team_uid);
let actual_link = AdminActions::admin_panel_link_for_team(team_uid);
assert_eq!(actual_link, expected_link);
}
}
@@ -0,0 +1,774 @@
#[cfg_attr(target_family = "wasm", allow(unused_imports))]
use std::{
path::{Path, PathBuf},
time::Duration,
};
use pathfinder_color::ColorU;
use warp_core::{
features::FeatureFlag, paths::home_relative_path, ui::theme::color::internal_colors,
};
use warpui::{
elements::{
Align, Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Empty, Expanded, Flex,
MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text,
},
fonts::{Properties, Weight},
platform::{file_picker::FilePickerError, FilePickerConfiguration},
r#async::{SpawnedFutureHandle, Timer},
ui_components::components::UiComponent,
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
themes::theme::Blend,
ui_components::{
buttons::icon_button,
dialog::{dialog_styles, Dialog},
icons::Icon,
},
view_components::{
action_button::{ActionButton, ButtonSize, PrimaryTheme, SecondaryTheme},
DismissibleToast,
},
workspace::ToastStack,
};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use git2::Repository as GitRepository;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
#[cfg(all(
feature = "local_fs",
not(target_family = "wasm"),
not(any(test, feature = "integration_tests"))
))]
use ai::index::full_source_code_embedding::manager::CodebaseIndexManagerEvent;
const DIALOG_WIDTH: f32 = 600.;
const AVAILABLE_LIST_MAX_HEIGHT: f32 = 260.;
const REPO_ROW_HORIZONTAL_PADDING: f32 = 10.;
const REPO_ROW_VERTICAL_PADDING: f32 = 8.;
const REPO_ROW_CORNER_RADIUS: f32 = 6.;
#[derive(Debug, Clone)]
pub enum AgentAssistedEnvironmentModalEvent {
Cancelled,
Confirmed { repo_paths: Vec<String> },
}
#[derive(Debug, Clone)]
pub enum AgentAssistedEnvironmentModalAction {
Cancel,
Confirm,
AddRepo(usize),
RemoveRepo(usize),
OpenDirectoryPicker,
DirectoryPicked(Result<PathBuf, FilePickerError>),
}
#[derive(Clone, Debug)]
struct RepoEntry {
name: String,
path: PathBuf,
}
pub struct AgentAssistedEnvironmentModal {
visible: bool,
available_repos: Vec<RepoEntry>,
selected_repo_paths: Vec<PathBuf>,
available_row_mouse_states: Vec<MouseStateHandle>,
selected_row_mouse_states: Vec<MouseStateHandle>,
close_button_mouse_state: MouseStateHandle,
available_scroll_state: ClippedScrollStateHandle,
available_repos_loading: bool,
available_repos_loading_timeout: Option<SpawnedFutureHandle>,
add_repo_button: ViewHandle<ActionButton>,
cancel_button: ViewHandle<ActionButton>,
create_button: ViewHandle<ActionButton>,
}
impl AgentAssistedEnvironmentModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let add_repo_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Add repo", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(
AgentAssistedEnvironmentModalAction::OpenDirectoryPicker,
);
})
});
let cancel_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Cancel", SecondaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(AgentAssistedEnvironmentModalAction::Cancel);
})
});
let create_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Create environment", PrimaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(AgentAssistedEnvironmentModalAction::Confirm);
})
});
let me = Self {
visible: false,
available_repos: Vec::new(),
selected_repo_paths: Vec::new(),
available_row_mouse_states: Vec::new(),
selected_row_mouse_states: Vec::new(),
close_button_mouse_state: MouseStateHandle::default(),
available_scroll_state: ClippedScrollStateHandle::default(),
available_repos_loading: false,
available_repos_loading_timeout: None,
add_repo_button,
cancel_button,
create_button,
};
#[cfg(all(
feature = "local_fs",
not(target_family = "wasm"),
not(any(test, feature = "integration_tests"))
))]
{
let index_manager = CodebaseIndexManager::handle(ctx);
ctx.subscribe_to_model(&index_manager, |me, _, event, ctx| {
if !me.visible {
return;
}
match event {
CodebaseIndexManagerEvent::SyncStateUpdated
| CodebaseIndexManagerEvent::NewIndexCreated
| CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata { .. }
| CodebaseIndexManagerEvent::IndexMetadataUpdated { .. } => {
me.refresh_available_repos(ctx);
if me.available_repos.is_empty() {
me.maybe_start_available_repos_loading(ctx);
} else {
me.stop_available_repos_loading();
}
ctx.notify();
}
_ => {}
}
});
}
me.update_create_button_disabled_state(ctx);
me
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn show(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = true;
self.selected_repo_paths.clear();
self.selected_row_mouse_states.clear();
self.stop_available_repos_loading();
self.refresh_available_repos(ctx);
self.maybe_start_available_repos_loading(ctx);
self.update_create_button_disabled_state(ctx);
ctx.notify();
}
pub fn hide(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = false;
self.stop_available_repos_loading();
ctx.notify();
}
fn update_create_button_disabled_state(&self, ctx: &mut ViewContext<Self>) {
let disabled = self.selected_repo_paths.is_empty();
self.create_button.update(ctx, |button, ctx| {
button.set_disabled(disabled, ctx);
});
}
fn refresh_available_repos(&mut self, ctx: &mut ViewContext<Self>) {
self.available_repos = available_indexed_repos(ctx);
self.available_row_mouse_states = self
.available_repos
.iter()
.map(|_| MouseStateHandle::default())
.collect();
}
fn maybe_start_available_repos_loading(&mut self, ctx: &mut ViewContext<Self>) {
if !cfg!(all(feature = "local_fs", not(target_family = "wasm"))) {
return;
}
if !self.visible || !self.available_repos.is_empty() || self.available_repos_loading {
return;
}
self.available_repos_loading = true;
if let Some(handle) = self.available_repos_loading_timeout.take() {
handle.abort();
}
self.available_repos_loading_timeout = Some(ctx.spawn_abortable(
Timer::after(Duration::from_millis(750)),
|me, _, ctx| {
me.available_repos_loading_timeout = None;
if !me.visible {
return;
}
me.refresh_available_repos(ctx);
// If repos are still empty after a brief wait, stop showing the loading state so we
// can surface the empty-state message.
if me.available_repos.is_empty() {
me.available_repos_loading = false;
} else {
me.stop_available_repos_loading();
}
ctx.notify();
},
|_, _| {},
));
}
fn stop_available_repos_loading(&mut self) {
self.available_repos_loading = false;
if let Some(handle) = self.available_repos_loading_timeout.take() {
handle.abort();
}
}
fn is_selected(&self, path: &PathBuf) -> bool {
self.selected_repo_paths.iter().any(|p| p == path)
}
fn add_repo_path(&mut self, path: PathBuf, ctx: &mut ViewContext<Self>) {
if self.is_selected(&path) {
return;
}
self.selected_repo_paths.push(path);
self.selected_row_mouse_states
.push(MouseStateHandle::default());
self.update_create_button_disabled_state(ctx);
ctx.notify();
}
fn add_repo(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
let Some(entry) = self.available_repos.get(index) else {
return;
};
self.add_repo_path(entry.path.clone(), ctx);
}
fn remove_repo(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
if index >= self.selected_repo_paths.len() {
return;
}
self.selected_repo_paths.remove(index);
self.selected_row_mouse_states.remove(index);
self.update_create_button_disabled_state(ctx);
ctx.notify();
}
fn render_section_title(&self, title: &str, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
Text::new(
title.to_string(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into())
.finish()
}
fn render_repo_info(name: String, path: String, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(2.)
.with_child(
Text::new(name, appearance.ui_font_family(), appearance.ui_font_size())
.with_color(theme.active_ui_text_color().into())
.finish(),
)
.with_child(
Text::new(
path,
appearance.ui_font_family(),
appearance.ui_font_size() * 0.9,
)
.with_color(theme.nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.finish()
}
fn render_selected_section(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let mut col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(8.);
col.add_child(self.render_section_title("Selected repos", appearance));
if self.selected_repo_paths.is_empty() {
col.add_child(
Text::new(
"No repos selected yet",
appearance.ui_font_family(),
appearance.ui_font_size() * 0.95,
)
.with_color(theme.nonactive_ui_text_color().into())
.finish(),
);
return col.finish();
}
for (idx, repo_path) in self.selected_repo_paths.iter().enumerate() {
let mouse_state = self
.selected_row_mouse_states
.get(idx)
.cloned()
.unwrap_or_default();
let name = repo_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("(unknown)")
.to_string();
let path_text = home_relative_path(repo_path);
let remove_action = AgentAssistedEnvironmentModalAction::RemoveRepo(idx);
let remove_button = icon_button(appearance, Icon::X, false, mouse_state)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(remove_action.clone());
})
.finish();
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Expanded::new(1., Self::render_repo_info(name, path_text, appearance)).finish(),
)
.with_child(remove_button)
.finish();
col.add_child(
Container::new(row)
.with_horizontal_padding(REPO_ROW_HORIZONTAL_PADDING)
.with_vertical_padding(REPO_ROW_VERTICAL_PADDING)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
REPO_ROW_CORNER_RADIUS,
)))
.with_background(
theme
.surface_2()
.blend(&internal_colors::accent_overlay_1(theme)),
)
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.finish(),
);
}
col.finish()
}
fn render_available_section(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let mut col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(8.);
let header = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Expanded::new(
1.,
self.render_section_title("Available indexed repos", appearance),
)
.finish(),
)
.with_child(
if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) {
Container::new(ChildView::new(&self.add_repo_button).finish())
.with_margin_left(8.)
.finish()
} else {
Empty::new().finish()
},
)
.finish();
col.add_child(header);
if self.available_repos.is_empty() {
let text = if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) {
if self.available_repos_loading {
"Loading locally indexed repos…"
} else {
"No locally indexed repos found yet. Index a repo, then try again."
}
} else {
"Local repo selection is unavailable in this build."
};
col.add_child(
Text::new(
text,
appearance.ui_font_family(),
appearance.ui_font_size() * 0.95,
)
.with_color(theme.nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
return col.finish();
}
let mut list = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(4.);
let mut has_any_available = false;
for (idx, entry) in self.available_repos.iter().enumerate() {
if self.is_selected(&entry.path) {
continue;
}
has_any_available = true;
let mouse_state = self
.available_row_mouse_states
.get(idx)
.cloned()
.unwrap_or_default();
let add_action = AgentAssistedEnvironmentModalAction::AddRepo(idx);
let add_button = icon_button(appearance, Icon::Plus, false, mouse_state)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(add_action.clone());
})
.finish();
let name = entry.name.clone();
let path_text = home_relative_path(&entry.path);
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Expanded::new(1., Self::render_repo_info(name, path_text, appearance)).finish(),
)
.with_child(add_button)
.finish();
list.add_child(
Container::new(row)
.with_horizontal_padding(REPO_ROW_HORIZONTAL_PADDING)
.with_vertical_padding(REPO_ROW_VERTICAL_PADDING)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
REPO_ROW_CORNER_RADIUS,
)))
.with_background(theme.surface_2())
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.finish(),
);
}
if !has_any_available {
col.add_child(
Text::new(
"All locally indexed repos are already selected.",
appearance.ui_font_family(),
appearance.ui_font_size() * 0.95,
)
.with_color(theme.nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
return col.finish();
}
// NOTE: `Scrollable` reserves horizontal space for its scrollbar gutter by default,
// which makes the list content slightly narrower than non-scrollable rows above.
// Overlay the scrollbar so it doesn't affect layout width.
let scrollable = ClippedScrollable::vertical(
self.available_scroll_state.clone(),
list.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_text_color().into(),
theme.active_ui_text_color().into(),
warpui::elements::Fill::None,
)
.with_overlayed_scrollbar()
.with_padding_start(0.)
.with_padding_end(0.)
.finish();
col.add_child(
ConstrainedBox::new(scrollable)
.with_max_height(AVAILABLE_LIST_MAX_HEIGHT)
.finish(),
);
col.finish()
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
fn show_not_a_repo_toast(&self, selected_path: &Path, ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
let path = home_relative_path(selected_path);
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast =
DismissibleToast::error(format!("Selected folder is not a Git repository: {path}"))
.with_object_id("agent_assisted_env_add_repo_not_git_repo".to_string());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
fn show_file_picker_error_toast(&self, error: &FilePickerError, ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(format!("{error}"));
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
fn handle_directory_picked(&mut self, selected_path: PathBuf, ctx: &mut ViewContext<Self>) {
let selected_path = dunce::canonicalize(&selected_path).unwrap_or(selected_path);
// `discover` accepts subdirectories; we normalize to the repo working tree root.
match GitRepository::discover(&selected_path)
.ok()
.and_then(|repo| repo.workdir().map(|workdir| workdir.to_path_buf()))
{
Some(repo_root) => {
self.add_repo_path(repo_root, ctx);
}
None => {
self.show_not_a_repo_toast(&selected_path, ctx);
}
}
}
fn open_directory_picker(&mut self, ctx: &mut ViewContext<Self>) {
if !cfg!(all(feature = "local_fs", not(target_family = "wasm"))) {
return;
}
let window_id = ctx.window_id();
let view_id = ctx.view_id();
ctx.open_file_picker(
move |paths_result, ctx| {
let result = paths_result.and_then(|paths| {
paths.into_iter().next().map(PathBuf::from).ok_or_else(|| {
FilePickerError::DialogFailed("No directory selected".to_string())
})
});
ctx.dispatch_typed_action_for_view(
window_id,
view_id,
&AgentAssistedEnvironmentModalAction::DirectoryPicked(result),
);
},
FilePickerConfiguration::new().folders_only(),
);
ctx.notify();
}
fn render_dialog(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let description = if FeatureFlag::FullSourceCodeEmbedding.is_enabled() {
"Select locally indexed repos to provide context for the environment creation agent."
} else {
"Select repos to provide context for the environment creation agent."
}
.to_string();
let close_button = icon_button(
appearance,
Icon::X,
false,
self.close_button_mouse_state.clone(),
)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(AgentAssistedEnvironmentModalAction::Cancel);
})
.finish();
let content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(16.)
.with_child(self.render_selected_section(appearance))
.with_child(self.render_available_section(appearance))
.finish();
let dialog = Dialog::new(
"Select repos for your environment".to_string(),
Some(description),
dialog_styles(appearance),
)
.with_close_button(close_button)
.with_child(content)
.with_separator()
.with_bottom_row_child(ChildView::new(&self.cancel_button).finish())
.with_bottom_row_child(
Container::new(ChildView::new(&self.create_button).finish())
.with_margin_left(12.)
.finish(),
)
.with_width(DIALOG_WIDTH)
.build();
let dialog = Dismiss::new(dialog.finish())
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(AgentAssistedEnvironmentModalAction::Cancel);
})
.finish();
Container::new(Align::new(dialog).finish())
.with_background_color(ColorU::new(0, 0, 0, 179))
.with_corner_radius(app.windows().window_corner_radius())
.finish()
}
}
impl Entity for AgentAssistedEnvironmentModal {
type Event = AgentAssistedEnvironmentModalEvent;
}
impl TypedActionView for AgentAssistedEnvironmentModal {
type Action = AgentAssistedEnvironmentModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AgentAssistedEnvironmentModalAction::Cancel => {
ctx.emit(AgentAssistedEnvironmentModalEvent::Cancelled);
}
AgentAssistedEnvironmentModalAction::Confirm => {
if self.selected_repo_paths.is_empty() {
return;
}
let repo_paths = self
.selected_repo_paths
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
ctx.emit(AgentAssistedEnvironmentModalEvent::Confirmed { repo_paths });
}
AgentAssistedEnvironmentModalAction::AddRepo(index) => {
self.add_repo(*index, ctx);
}
AgentAssistedEnvironmentModalAction::RemoveRepo(index) => {
self.remove_repo(*index, ctx);
}
AgentAssistedEnvironmentModalAction::OpenDirectoryPicker => {
self.open_directory_picker(ctx);
}
AgentAssistedEnvironmentModalAction::DirectoryPicked(result) => match result {
Ok(path) => {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
self.handle_directory_picked(path.clone(), ctx);
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
{
self.add_repo_path(path.clone(), ctx);
}
}
Err(error) => {
self.show_file_picker_error_toast(error, ctx);
}
},
}
}
}
impl View for AgentAssistedEnvironmentModal {
fn ui_name() -> &'static str {
"AgentAssistedEnvironmentModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
if !self.visible {
return Empty::new().finish();
}
let appearance = Appearance::as_ref(app);
self.render_dialog(appearance, app)
}
}
fn available_indexed_repos(app: &AppContext) -> Vec<RepoEntry> {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
let mut repos: Vec<RepoEntry> = CodebaseIndexManager::as_ref(app)
.get_codebase_index_statuses(app)
.filter_map(|(root, status)| {
status.has_synced_version().then(|| {
let name = root
.file_name()
.and_then(|s| s.to_str())
.map(ToOwned::to_owned)
.unwrap_or_else(|| root.to_string_lossy().into_owned());
RepoEntry {
name,
path: root.clone(),
}
})
})
.collect();
repos.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
repos
}
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
{
let _ = app;
Vec::new()
}
}
#[cfg(test)]
#[path = "agent_assisted_environment_modal_tests.rs"]
mod tests;
@@ -0,0 +1,364 @@
use super::*;
use crate::server::server_api::ServerApiProvider;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspace::ToastStack;
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
use std::path::PathBuf;
use warp_core::ui::appearance::Appearance;
use warpui::elements::{ChildView, Empty};
use warpui::platform::WindowStyle;
use warpui::{App, AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle};
fn init_modal_test_models(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| ToastStack);
// The modal queries CodebaseIndexManager for locally indexed repos.
// Register a test instance so `available_indexed_repos(...)` doesn't panic.
app.add_singleton_model(|ctx| {
CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx)
});
}
#[derive(Default)]
// Simple view that owns the modal and records its emitted events.
struct ModalHarness {
modal: Option<ViewHandle<AgentAssistedEnvironmentModal>>,
events: Vec<AgentAssistedEnvironmentModalEvent>,
}
impl ModalHarness {
fn new(ctx: &mut ViewContext<Self>) -> Self {
let modal = ctx.add_typed_action_view(AgentAssistedEnvironmentModal::new);
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
me.events.push(event.clone());
ctx.notify();
});
Self {
modal: Some(modal),
events: Vec::new(),
}
}
fn modal(&self) -> ViewHandle<AgentAssistedEnvironmentModal> {
self.modal
.clone()
.expect("ModalHarness.modal should be initialized")
}
}
impl Entity for ModalHarness {
type Event = ();
}
impl View for ModalHarness {
fn ui_name() -> &'static str {
"AgentAssistedEnvironmentModalTestHarness"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
let Some(modal) = &self.modal else {
return Empty::new().finish();
};
ChildView::new(modal).finish()
}
}
impl TypedActionView for ModalHarness {
type Action = ();
}
#[test]
fn test_modal_default_render_is_empty() {
// When not visible, the modal renders as an Empty element.
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new);
harness.read(&app, |harness, ctx| {
let modal = harness.modal();
let element = modal.as_ref(ctx).render(ctx);
let text_content = element.debug_text_content().unwrap_or_default();
assert!(
text_content.is_empty(),
"Expected empty modal render when hidden, got: {}",
text_content
);
});
})
}
#[test]
fn test_modal_show_renders_expected_copy_with_empty_repos_message() {
// We validate the copy via the section renderers (selected/available) rather than the full dialog,
// because the dialog includes icons/buttons that may rely on asset providers in unit tests.
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new);
// Show modal, then force it into the non-loading empty state so the content is deterministic.
harness.update(&mut app, |harness, ctx| {
harness.events.clear();
let modal = harness.modal();
modal.update(ctx, |modal, ctx| {
modal.show(ctx);
modal.stop_available_repos_loading();
modal.available_repos.clear();
});
});
harness.read(&app, |harness, ctx| {
let appearance = Appearance::as_ref(ctx);
let modal = harness.modal();
let modal = modal.as_ref(ctx);
let selected_section = modal.render_selected_section(appearance);
let selected_text = selected_section.debug_text_content().unwrap_or_default();
assert!(
selected_text.contains("Selected repos"),
"Expected selected section title in rendered content: {}",
selected_text
);
assert!(
selected_text.contains("No repos selected yet"),
"Expected selected empty-state message in rendered content: {}",
selected_text
);
let available_section = modal.render_available_section(appearance);
let available_text = available_section.debug_text_content().unwrap_or_default();
assert!(
available_text.contains("Available indexed repos"),
"Expected available section title in rendered content: {}",
available_text
);
assert!(
available_text
.contains("No locally indexed repos found yet. Index a repo, then try again."),
"Expected available empty-state message in rendered content: {}",
available_text
);
});
})
}
#[test]
fn test_modal_cancel_emits_event() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new);
harness.update(&mut app, |harness, ctx| {
harness.events.clear();
let modal = harness.modal();
modal.update(ctx, |modal, ctx| {
modal.handle_action(&AgentAssistedEnvironmentModalAction::Cancel, ctx);
});
});
harness.read(&app, |harness, _ctx| {
assert!(
matches!(
harness.events.as_slice(),
[AgentAssistedEnvironmentModalEvent::Cancelled]
),
"Expected a single Cancelled event"
);
});
})
}
#[test]
fn test_modal_confirm_only_emits_event_when_repos_selected() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new);
// Confirm with no selection should not emit.
harness.update(&mut app, |harness, ctx| {
harness.events.clear();
let modal = harness.modal();
modal.update(ctx, |modal, ctx| {
modal.selected_repo_paths.clear();
modal.selected_row_mouse_states.clear();
modal.handle_action(&AgentAssistedEnvironmentModalAction::Confirm, ctx);
});
});
harness.read(&app, |harness, _ctx| {
assert!(
harness.events.is_empty(),
"Did not expect any events when confirming with no selected repos"
);
});
// Confirm with a selected repo should emit Confirmed.
let selected_repo = PathBuf::from("/tmp/repo-a");
harness.update(&mut app, |harness, ctx| {
harness.events.clear();
let modal = harness.modal();
modal.update(ctx, |modal, ctx| {
modal.selected_repo_paths = vec![selected_repo.clone()];
modal.selected_row_mouse_states = vec![MouseStateHandle::default()];
modal.handle_action(&AgentAssistedEnvironmentModalAction::Confirm, ctx);
});
});
harness.read(&app, |harness, _ctx| {
assert!(
matches!(
harness.events.as_slice(),
[AgentAssistedEnvironmentModalEvent::Confirmed { repo_paths }]
if repo_paths == &vec!["/tmp/repo-a".to_string()]
),
"Expected a single Confirmed event with selected repo path"
);
});
})
}
#[test]
fn test_modal_show_clears_selection() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new);
harness.update(&mut app, |harness, ctx| {
let modal = harness.modal();
modal.update(ctx, |modal, ctx| {
modal.selected_repo_paths = vec![PathBuf::from("/tmp/should-clear")];
modal.selected_row_mouse_states = vec![MouseStateHandle::default()];
modal.show(ctx);
assert!(modal.selected_repo_paths.is_empty());
assert!(modal.selected_row_mouse_states.is_empty());
});
});
})
}
#[test]
fn test_modal_directory_picked_adds_repo_and_confirm_emits_event() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new);
let tmp_dir = tempfile::TempDir::new().expect("TempDir should be creatable");
git2::Repository::init(tmp_dir.path()).expect("git repo should be init-able");
let selected_repo = tmp_dir.path().to_path_buf();
let expected_repo_string = dunce::canonicalize(tmp_dir.path())
.unwrap_or_else(|_| tmp_dir.path().to_path_buf())
.to_string_lossy()
.to_string();
harness.update(&mut app, |harness, ctx| {
harness.events.clear();
let modal = harness.modal();
modal.update(ctx, |modal, ctx| {
modal.show(ctx);
modal.stop_available_repos_loading();
modal.handle_action(
&AgentAssistedEnvironmentModalAction::DirectoryPicked(
Ok(selected_repo.clone()),
),
ctx,
);
modal.handle_action(&AgentAssistedEnvironmentModalAction::Confirm, ctx);
});
});
harness.read(&app, |harness, _ctx| {
let [AgentAssistedEnvironmentModalEvent::Confirmed { repo_paths }] =
harness.events.as_slice()
else {
panic!(
"Expected a single Confirmed event with selected repo path, got: {:?}",
harness.events
);
};
assert_eq!(repo_paths.len(), 1);
let actual = dunce::canonicalize(PathBuf::from(&repo_paths[0]))
.unwrap_or_else(|_| PathBuf::from(&repo_paths[0]))
.to_string_lossy()
.to_string();
assert_eq!(actual, expected_repo_string);
});
})
}
#[test]
fn test_modal_directory_picked_dedupes_paths() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new);
let tmp_dir = tempfile::TempDir::new().expect("TempDir should be creatable");
git2::Repository::init(tmp_dir.path()).expect("git repo should be init-able");
let selected_repo = tmp_dir.path().to_path_buf();
harness.update(&mut app, |harness, ctx| {
let modal = harness.modal();
modal.update(ctx, |modal, ctx| {
modal.show(ctx);
modal.handle_action(
&AgentAssistedEnvironmentModalAction::DirectoryPicked(
Ok(selected_repo.clone()),
),
ctx,
);
modal.handle_action(
&AgentAssistedEnvironmentModalAction::DirectoryPicked(
Ok(selected_repo.clone()),
),
ctx,
);
assert_eq!(modal.selected_repo_paths.len(), 1);
assert_eq!(modal.selected_row_mouse_states.len(), 1);
});
});
})
}
#[test]
fn test_modal_directory_picked_rejects_non_repos() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new);
let tmp_dir = tempfile::TempDir::new().expect("TempDir should be creatable");
let selected_dir = tmp_dir.path().to_path_buf();
harness.update(&mut app, |harness, ctx| {
let modal = harness.modal();
modal.update(ctx, |modal, ctx| {
modal.show(ctx);
modal.handle_action(
&AgentAssistedEnvironmentModalAction::DirectoryPicked(Ok(selected_dir.clone())),
ctx,
);
assert!(modal.selected_repo_paths.is_empty());
assert!(modal.selected_row_mouse_states.is_empty());
});
});
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
pub mod overage_limit_modal;
pub mod usage_history_entry;
pub mod usage_history_model;
@@ -0,0 +1,357 @@
use warpui::{
elements::{Align, Clipped},
ui_components::components::{Coords, UiComponentStyles},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
Appearance,
};
use warpui::{
elements::{
Border, ChildView, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex,
MouseStateHandle, Padding, ParentElement, Radius, Text,
},
ui_components::{button::ButtonVariant, components::UiComponent},
};
const MAXIMUM_SPENDING_LIMIT_CENTS: u32 = 999999999;
pub struct SpendingLimitModal {
amount_editor: ViewHandle<EditorView>,
cancel_button_mouse_state: MouseStateHandle,
update_button_mouse_state: MouseStateHandle,
input_error_state: Option<SpendingLimitModalInputErrorState>,
}
#[derive(Debug, Clone)]
pub enum SpendingLimitModalEvent {
Close,
Update { amount_cents: u32 },
}
impl Entity for SpendingLimitModal {
type Event = SpendingLimitModalEvent;
}
#[derive(Debug, Clone)]
pub enum SpendingLimitModalAction {
Close,
Update,
}
pub enum SpendingLimitModalInputErrorState {
InvalidNumberFormat,
NumberOutOfRange,
}
impl SpendingLimitModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let font_family = Appearance::as_ref(ctx).ui_font_family();
let amount_editor = ctx.add_typed_action_view(|ctx| {
let options = SingleLineEditorOptions {
text: TextOptions {
font_family_override: Some(font_family),
..Default::default()
},
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("50.00", ctx);
editor
});
ctx.subscribe_to_view(&amount_editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
Self {
amount_editor,
cancel_button_mouse_state: MouseStateHandle::default(),
update_button_mouse_state: MouseStateHandle::default(),
input_error_state: None,
}
}
fn parse_amount(&self, app: &AppContext) -> Option<u32> {
let text = self.amount_editor.as_ref(app).buffer_text(app);
let text = text.trim();
if text.is_empty() {
return None;
}
let cleaned = text.strip_prefix('$').unwrap_or(text);
if let Ok(dollars) = cleaned.parse::<f64>() {
Some((dollars * 100.0).round() as u32)
} else {
None
}
}
fn validate_input(&mut self, ctx: &mut ViewContext<Self>) {
let text = self.amount_editor.as_ref(ctx).buffer_text(ctx);
if !self.is_valid_us_currency_format(&text) {
self.input_error_state = Some(SpendingLimitModalInputErrorState::InvalidNumberFormat);
} else if self
.parse_amount(ctx)
.is_some_and(|cents| !self.is_valid_number_range(cents))
{
self.input_error_state = Some(SpendingLimitModalInputErrorState::NumberOutOfRange);
} else {
self.input_error_state = None;
}
ctx.notify();
}
fn is_valid_number_range(&self, amount_cents: u32) -> bool {
if amount_cents < 1 {
return false;
}
if amount_cents > MAXIMUM_SPENDING_LIMIT_CENTS {
return false;
}
true
}
fn is_valid_us_currency_format(&self, text: &str) -> bool {
if text.is_empty() {
return true;
}
let cleaned = text.strip_prefix('$').unwrap_or(text);
let decimal_count = cleaned.chars().filter(|&c| c == '.').count();
if decimal_count > 1 {
return false;
}
if !cleaned.chars().all(|c| c.is_ascii_digit() || c == '.') {
return false;
}
if let Some(decimal_pos) = cleaned.find('.') {
let after_decimal = &cleaned[decimal_pos + 1..];
if after_decimal.len() > 2 {
return false;
}
}
true
}
pub fn update_amount_editor(&self, cents: u32, ctx: &mut ViewContext<Self>) {
let placeholder_text = format!("{:.2}", cents as f64 / 100.0);
self.amount_editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(&placeholder_text, ctx);
editor.clear_buffer(ctx);
});
ctx.notify();
}
fn error_text(&self) -> Option<String> {
match self.input_error_state {
Some(SpendingLimitModalInputErrorState::InvalidNumberFormat) => {
Some("Please enter a valid currency amount".to_string())
}
Some(SpendingLimitModalInputErrorState::NumberOutOfRange) => {
Some("Please enter a price between $0.01 and $10,000,000".to_string())
}
None => None,
}
}
pub fn focus_input(&self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.amount_editor);
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Enter => {
if self.input_error_state.is_none() {
if let Some(amount_cents) = self.parse_amount(ctx) {
ctx.emit(SpendingLimitModalEvent::Update { amount_cents });
}
}
}
EditorEvent::Escape => {
ctx.emit(SpendingLimitModalEvent::Close);
}
EditorEvent::Edited(_) => {
self.validate_input(ctx);
}
_ => {}
}
}
}
impl View for SpendingLimitModal {
fn ui_name() -> &'static str {
"SpendingLimitModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let description_text = Text::new(
"Warp will prevent use of premium models when this dollar limit is reached. Resets on a monthly basis.",
appearance.ui_font_family(),
14.,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish();
let additional_note_text = Text::new(
"Note that AI credits made near your chosen limit may exceed it by a few dollars.",
appearance.ui_font_family(),
12.,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish();
let description_section = Flex::column()
.with_child(
Container::new(description_text)
.with_margin_bottom(8.)
.finish(),
)
.with_child(additional_note_text)
.finish();
let input_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new("$", appearance.ui_font_family(), appearance.ui_font_size())
.with_color(theme.active_ui_text_color().into())
.finish(),
)
.with_child(
Expanded::new(
1.,
Container::new(
Clipped::new(ChildView::new(&self.amount_editor).finish()).finish(),
)
.with_margin_left(12.)
.finish(),
)
.finish(),
)
.finish();
let border_color = if self.input_error_state.is_some() {
theme.ui_error_color().into()
} else {
theme.outline()
};
let input_container = Container::new(input_row)
.with_padding(Padding::uniform(8.).with_left(16.).with_right(16.))
.with_border(Border::all(1.).with_border_fill(border_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish();
let button_style = UiComponentStyles {
font_size: Some(14.),
padding: Some(Coords::uniform(8.).left(12.).right(12.)),
..Default::default()
};
let mut update_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.update_button_mouse_state.clone(),
)
.with_text_label("Update".to_string())
.with_style(button_style);
if self.input_error_state.is_some() {
update_button = update_button.disabled();
}
let buttons_row = Flex::row()
.with_child(
appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.cancel_button_mouse_state.clone(),
)
.with_text_label("Cancel".to_string())
.with_style(button_style)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(SpendingLimitModalAction::Close);
})
.finish(),
)
.with_child(
Container::new(
update_button
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(SpendingLimitModalAction::Update);
})
.finish(),
)
.with_margin_left(12.)
.finish(),
)
.finish();
let mut main_column = Flex::column()
.with_child(
Container::new(description_section)
.with_margin_bottom(16.)
.finish(),
)
.with_child(
Container::new(input_container)
.with_margin_bottom(if self.input_error_state.is_some() {
8.
} else {
24.
})
.finish(),
);
if let Some(error_text) = self.error_text() {
let error_text = Text::new(error_text, appearance.ui_font_family(), 12.)
.with_color(theme.ui_error_color())
.finish();
main_column =
main_column.with_child(Container::new(error_text).with_margin_bottom(24.).finish());
}
main_column
.with_child(Align::new(buttons_row).right().finish())
.finish()
}
}
impl TypedActionView for SpendingLimitModal {
type Action = SpendingLimitModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
SpendingLimitModalAction::Close => {
self.amount_editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
ctx.emit(SpendingLimitModalEvent::Close);
}
SpendingLimitModalAction::Update => {
if let Some(amount_cents) = self.parse_amount(ctx) {
ctx.emit(SpendingLimitModalEvent::Update { amount_cents });
}
}
}
}
}
@@ -0,0 +1,225 @@
use crate::{
ai::blocklist::format_credits,
ai::blocklist::usage::conversation_usage_view::{
ConversationUsageInfo, ConversationUsageView, DisplayMode,
},
settings_view::billing_and_usage_page::BillingAndUsagePageAction,
ui_components::{blended_colors, icons::Icon},
};
use chrono::Local;
use warp_core::ui::appearance::Appearance;
use warp_graphql::queries::get_conversation_usage::ConversationUsage;
use warpui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
Shrinkable, Text,
},
platform::Cursor,
AppContext, Element, View,
};
pub struct UsageHistoryEntry {
// If no entry is provided, we will assume that this is a placeholder entry
// to display in the loading UI.
entry: Option<ConversationUsage>,
is_expanded: bool,
mouse_state: Option<MouseStateHandle>,
tooltip_mouse_state: MouseStateHandle,
}
impl UsageHistoryEntry {
pub fn new(
entry: Option<ConversationUsage>,
is_expanded: bool,
mouse_state: Option<MouseStateHandle>,
tooltip_mouse_state: MouseStateHandle,
) -> Self {
Self {
entry,
mouse_state,
is_expanded,
tooltip_mouse_state,
}
}
pub fn render(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let mut res = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(self.render_header(appearance));
if let Some(entry) = &self.entry {
if self.is_expanded {
res = res
.with_child(
// Separator between header and usage component
Container::new(Empty::new().finish())
.with_border(
Border::top(2.0).with_border_fill(appearance.theme().outline()),
)
.with_overdraw_bottom(0.)
.finish(),
)
.with_child(
ConversationUsageView::new(
ConversationUsageInfo::from(entry),
DisplayMode::Settings,
None,
self.tooltip_mouse_state.clone(),
)
.render(app),
);
}
}
Container::new(res.finish())
.with_border(Border::all(2.).with_border_fill(appearance.theme().surface_3()))
.with_background(appearance.theme().surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let Some(entry) = &self.entry else {
return self.render_loading_entry(appearance);
};
let Some(mouse_state) = &self.mouse_state else {
// If there is a provided entry, there should always be a mouse state as well.
log::error!("Mouse state is required to render usage history entry header");
return Empty::new().finish();
};
let title_text = Text::new_inline(entry.title.clone(), appearance.ui_font_family(), 14.)
.with_color(
appearance
.theme()
.main_text_color(appearance.theme().surface_2())
.into(),
)
.finish();
let formatted_time = entry
.last_updated
.utc()
.with_timezone(&Local)
.format("%-m/%-d/%y %-I:%M %p")
.to_string();
let time_text = Text::new_inline(formatted_time, appearance.ui_font_family(), 12.)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().surface_1(),
))
.finish();
let credits_spent = Text::new_inline(
format_credits(entry.usage_metadata.credits_spent as f32),
appearance.ui_font_family(),
14.,
)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().surface_1(),
))
.finish();
let chevron_icon = if self.is_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let chevron = ConstrainedBox::new(
chevron_icon
.to_warpui_icon(appearance.theme().foreground())
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish();
let header_row = Container::new(
Flex::row()
.with_child(
Shrinkable::new(
1.,
Container::new(
Flex::column()
.with_child(title_text)
.with_child(Container::new(time_text).with_margin_top(4.).finish())
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.finish(),
)
.finish(),
)
.finish(),
)
.with_child(
Flex::row()
.with_child(credits_spent)
.with_child(chevron)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.finish(),
)
.with_uniform_padding(12.)
.with_corner_radius(if self.is_expanded {
CornerRadius::with_top(Radius::Pixels(6.))
} else {
CornerRadius::with_all(Radius::Pixels(6.))
})
.with_background(appearance.theme().surface_2())
.finish();
let conversation_id = entry.conversation_id.clone();
Hoverable::new(mouse_state.clone(), |_| header_row)
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(BillingAndUsagePageAction::ToggleUsageEntryExpanded {
conversation_id: conversation_id.clone(),
});
})
.finish()
}
/// Render a placeholder entry for the loading state
fn render_loading_entry(&self, appearance: &Appearance) -> Box<dyn Element> {
let left_side = Flex::column()
.with_child(self.render_empty_text_placeholder(360., 16., appearance))
.with_child(self.render_empty_text_placeholder(160., 12., appearance))
.with_spacing(4.)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.finish();
Container::new(
Flex::row()
.with_child(left_side)
.with_child(self.render_empty_text_placeholder(52., 16., appearance))
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.finish(),
)
.with_uniform_padding(12.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.with_background(appearance.theme().surface_2())
.finish()
}
/// Renders an empty rectangle to represent loading text
fn render_empty_text_placeholder(
&self,
width: f32,
height: f32,
appearance: &Appearance,
) -> Box<dyn Element> {
Container::new(Empty::new().finish())
.with_padding_left(width)
.with_padding_top(height)
.with_background(appearance.theme().surface_3())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
}
}
@@ -0,0 +1,139 @@
use std::sync::Arc;
use warp_core::report_error;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::auth::AuthStateProvider;
use crate::server::server_api::{auth::AuthClient, ServerApiProvider};
use warp_graphql::scalars::Time;
const PAGE_SIZE: i32 = 20;
pub struct UsageHistoryModel {
auth_client: Arc<dyn AuthClient>,
entries: Vec<warp_graphql::queries::get_conversation_usage::ConversationUsage>,
is_loading: bool,
// Whether the server indicated that there may be more entries to load.
has_more_entries: bool,
}
impl Entity for UsageHistoryModel {
type Event = ();
}
impl SingletonEntity for UsageHistoryModel {}
impl UsageHistoryModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
Self {
auth_client,
entries: Vec::new(),
is_loading: false,
has_more_entries: true,
}
}
pub fn entries(&self) -> &[warp_graphql::queries::get_conversation_usage::ConversationUsage] {
&self.entries
}
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn has_more_entries(&self) -> bool {
self.has_more_entries
}
/// Fetches conversation usage over the past 30 days.
/// If some usage has already been loaded, this fetches the same number of entries.
/// If no usage has been loaded, this fetches PAGE_SIZE entries.
pub fn refresh_usage_history_async(&mut self, ctx: &mut ModelContext<Self>) {
if self.is_loading || !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
return;
}
// If the user has already loaded some number of entries,
// we should load that same number of items on refresh so that the list doesn't shrink
// every time the page is refreshed.
let num_items_to_fetch = if self.entries.is_empty() {
PAGE_SIZE
} else {
self.entries.len() as i32
};
// Reset pagination state and clear any existing entries.
self.entries.clear();
self.has_more_entries = true;
self.fetch_next_page(num_items_to_fetch, None, ctx);
}
/// Fetches the next page of conversation usage entries, appending them to the existing list.
pub fn load_more_usage_history_async(&mut self, ctx: &mut ModelContext<Self>) {
if self.is_loading || !self.has_more_entries {
return;
}
let last_updated_end_timestamp: Option<Time> =
self.entries.last().map(|entry| entry.last_updated);
if last_updated_end_timestamp.is_none() {
return;
}
self.fetch_next_page(PAGE_SIZE, last_updated_end_timestamp, ctx);
}
/// Fetches the next page of conversation usage entries, appending them to the existing list.
/// last_updated_end_timestamp is the timestamp of the last entry in the existing list,
/// and is used to paginate the results and only return entries that we don't already have.
fn fetch_next_page(
&mut self,
limit: i32,
last_updated_end_timestamp: Option<Time>,
ctx: &mut ModelContext<Self>,
) {
// If no time stamp is provided for pagination, we can assume that this is the first page of results.
let is_initial_load = last_updated_end_timestamp.is_none();
let auth_client = self.auth_client.clone();
if is_initial_load {
self.is_loading = true;
ctx.notify();
}
ctx.spawn(
async move {
auth_client
.get_conversation_usage_history(
Some(30),
Some(limit),
last_updated_end_timestamp,
)
.await
},
move |me, result, ctx| {
me.is_loading = false;
match result {
Ok(entries) => {
let fetched_count = entries.len() as i32;
// If we received fewer than requested, assume there are no more entries.
me.has_more_entries = fetched_count == limit;
if !is_initial_load {
me.entries.extend(entries);
} else {
me.entries = entries;
}
}
Err(e) => {
report_error!(e.context("Failed to fetch conversation usage"));
}
}
ctx.notify();
},
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
use super::{sort_user_items_in_place, SortKey, SortOrder, UserSortingCriteria};
#[test]
pub fn test_default_sorting_pins_current_user_first_then_display_name_asc() {
let mut items = vec![
UserSortingCriteria::new("Zed".to_string(), 10, ()),
UserSortingCriteria::new("Alice".to_string(), 5, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
];
sort_user_items_in_place(&mut items, "Bob", None, SortOrder::Asc);
// Expected: Bob (current user) first, then Alice, then Zed (by display name asc)
assert_eq!(items[0].display_name, "Bob");
assert_eq!(items[1].display_name, "Alice");
assert_eq!(items[2].display_name, "Zed");
}
#[test]
fn test_display_name_az_sorting_pins_current_user() {
let mut items = vec![
UserSortingCriteria::new("Zed".to_string(), 10, ()),
UserSortingCriteria::new("Alice".to_string(), 5, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
UserSortingCriteria::new("charlie@example.com".to_string(), 8, ()), // Using email as display name fallback
];
sort_user_items_in_place(
&mut items,
"Bob",
Some(SortKey::DisplayName),
SortOrder::Asc,
);
// Expected: Bob (current user) first, then Alice, charlie@ (fallback to email), Zed
assert_eq!(items[0].display_name, "Bob");
assert_eq!(items[1].display_name, "Alice");
assert_eq!(items[2].display_name, "charlie@example.com"); // Email as display name fallback
assert_eq!(items[3].display_name, "Zed");
}
#[test]
fn test_display_name_za_sorting_pins_current_user() {
let mut items = vec![
UserSortingCriteria::new("Zed".to_string(), 10, ()),
UserSortingCriteria::new("Alice".to_string(), 5, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
];
sort_user_items_in_place(
&mut items,
"Alice",
Some(SortKey::DisplayName),
SortOrder::Desc,
);
// Expected: Alice (current user) first, then Zed, Bob (by name desc)
assert_eq!(items[0].display_name, "Alice");
assert_eq!(items[1].display_name, "Zed");
assert_eq!(items[2].display_name, "Bob");
}
#[test]
fn test_requests_usage_desc_sorting_pins_current_user_with_display_name_tie_breaker() {
let mut items = vec![
UserSortingCriteria::new("Alice".to_string(), 10, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
UserSortingCriteria::new("Charlie".to_string(), 10, ()), // Same usage as Alice
UserSortingCriteria::new("Diana".to_string(), 5, ()),
];
sort_user_items_in_place(
&mut items,
"Diana",
Some(SortKey::Requests),
SortOrder::Desc,
);
// Expected: Diana (current user) first, then Bob (15), then Alice/Charlie by name (10 tie)
assert_eq!(items[0].display_name, "Diana");
assert_eq!(items[1].display_name, "Bob"); // Highest usage (15)
assert_eq!(items[2].display_name, "Alice"); // Tied at 10, "Alice" < "Charlie"
assert_eq!(items[3].display_name, "Charlie");
}
#[test]
fn test_requests_usage_asc_sorting_pins_current_user_with_display_name_tie_breaker() {
let mut items = vec![
UserSortingCriteria::new("Alice".to_string(), 10, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
UserSortingCriteria::new("Charlie".to_string(), 10, ()), // Same usage as Alice
UserSortingCriteria::new("Diana".to_string(), 5, ()),
];
sort_user_items_in_place(&mut items, "Bob", Some(SortKey::Requests), SortOrder::Asc);
// Expected: Bob (current user) first, then Diana (5), then Alice/Charlie by name (10 tie)
assert_eq!(items[0].display_name, "Bob");
assert_eq!(items[1].display_name, "Diana"); // Lowest usage (5)
assert_eq!(items[2].display_name, "Alice"); // Tied at 10, "Alice" < "Charlie"
assert_eq!(items[3].display_name, "Charlie");
}
#[test]
fn test_display_name_az_sorting_with_emails() {
let mut items = vec![
UserSortingCriteria::new("zuser@example.com".to_string(), 10, ()),
UserSortingCriteria::new("Alice".to_string(), 5, ()),
UserSortingCriteria::new("buser@example.com".to_string(), 15, ()),
];
sort_user_items_in_place(
&mut items,
"Alice",
Some(SortKey::DisplayName),
SortOrder::Asc,
);
// Expected: Alice (current user) first, then buser@... < zuser@... (by email fallback)
assert_eq!(items[0].display_name, "Alice");
assert_eq!(items[1].display_name, "buser@example.com"); // Email as display name
assert_eq!(items[2].display_name, "zuser@example.com"); // Email as display name
}
#[test]
fn test_case_insensitive_display_name_sorting() {
let mut items = vec![
UserSortingCriteria::new("alice".to_string(), 10, ()),
UserSortingCriteria::new("Bob".to_string(), 5, ()),
UserSortingCriteria::new("CHARLIE".to_string(), 8, ()),
UserSortingCriteria::new("Diana".to_string(), 12, ()),
];
sort_user_items_in_place(
&mut items,
"Diana",
Some(SortKey::DisplayName),
SortOrder::Asc,
);
// Expected: Diana (current user) first, then alice, Bob, CHARLIE (case-insensitive asc)
assert_eq!(items[0].display_name, "Diana");
assert_eq!(items[1].display_name, "alice"); // "alice" (lowercase)
assert_eq!(items[2].display_name, "Bob"); // "Bob"
assert_eq!(items[3].display_name, "CHARLIE"); // "CHARLIE"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
use warpui::{
elements::{ChildView, Container, Dismiss, Empty},
ui_components::components::UiComponent,
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
server::ids::SyncId,
ui_components::dialog::{dialog_styles, Dialog},
view_components::action_button::{ActionButton, DangerPrimaryTheme, NakedTheme},
};
const DIALOG_WIDTH: f32 = 450.;
pub enum DeleteEnvironmentConfirmationDialogEvent {
Cancel,
Confirm(SyncId),
}
#[derive(Debug)]
pub enum DeleteEnvironmentConfirmationDialogAction {
Cancel,
Confirm,
}
pub struct DeleteEnvironmentConfirmationDialog {
pub(crate) visible: bool,
pub(crate) env_id: Option<SyncId>,
pub(crate) env_name: String,
cancel_button: ViewHandle<ActionButton>,
confirm_button: ViewHandle<ActionButton>,
}
impl DeleteEnvironmentConfirmationDialog {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let cancel_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(DeleteEnvironmentConfirmationDialogAction::Cancel);
})
});
let confirm_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Delete environment", DangerPrimaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(DeleteEnvironmentConfirmationDialogAction::Confirm);
})
});
Self {
visible: false,
env_id: None,
env_name: String::new(),
cancel_button,
confirm_button,
}
}
pub fn show(&mut self, env_id: SyncId, env_name: String, ctx: &mut ViewContext<Self>) {
self.env_id = Some(env_id);
self.env_name = env_name;
self.visible = true;
ctx.notify();
}
pub fn hide(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = false;
ctx.notify();
}
}
impl Entity for DeleteEnvironmentConfirmationDialog {
type Event = DeleteEnvironmentConfirmationDialogEvent;
}
impl View for DeleteEnvironmentConfirmationDialog {
fn ui_name() -> &'static str {
"DeleteEnvironmentConfirmationDialog"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
if !self.visible {
return Empty::new().finish();
}
let appearance = Appearance::as_ref(app);
let description = format!(
"Are you sure you want to remove the {} environment?",
self.env_name
);
let dialog = Dialog::new(
"Delete environment?".to_string(),
Some(description),
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(DeleteEnvironmentConfirmationDialogAction::Cancel)
})
.finish()
}
}
impl TypedActionView for DeleteEnvironmentConfirmationDialog {
type Action = DeleteEnvironmentConfirmationDialogAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
DeleteEnvironmentConfirmationDialogAction::Cancel => {
ctx.emit(DeleteEnvironmentConfirmationDialogEvent::Cancel)
}
DeleteEnvironmentConfirmationDialogAction::Confirm => {
if let Some(env_id) = self.env_id {
ctx.emit(DeleteEnvironmentConfirmationDialogEvent::Confirm(env_id));
}
}
}
}
}
@@ -0,0 +1,343 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ai::index::full_source_code_embedding::manager::{
CodebaseIndexManager, CodebaseIndexManagerEvent,
};
use settings::Setting;
use warp_util::path::user_friendly_path;
use warpui::{
elements::{
Border, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
MainAxisSize, MouseStateHandle, ParentElement, Text,
},
platform::Cursor,
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
ai::persisted_workspace::{PersistedWorkspace, PersistedWorkspaceEvent},
appearance::Appearance,
ui_components::icons,
view_components::action_button::{ActionButton, SecondaryTheme},
view_components::{DropdownItem, FilterableDropdown},
workspace::tab_settings::{
DirectoryTabColor, DirectoryTabColors, TabSettings, TabSettingsChangedEvent,
},
};
const ADD_DIRECTORY_LABEL: &str = "+ Add directory…";
const BUTTON_LABEL: &str = "Add directory color";
const MENU_WIDTH: f32 = 340.;
/// A dropdown used by the Directory tab colors settings widget, with a button fallback
/// when there are no known repos left to show in the dropdown.
///
/// Lists known repos (from `CodebaseIndexManager` and `PersistedWorkspace`) that
/// are not yet present in the user's `directory_tab_colors` with a non-`Suppressed`
/// color, and exposes a pinned `+ Add directory…` footer that falls back to the
/// native folder picker.
///
/// Emits:
/// - [`DirectoryColorAddPickerEvent::Selected`] when the user picks a row.
/// - [`DirectoryColorAddPickerEvent::RequestAddFromFilePicker`] when the user clicks
/// the pinned footer or the fallback button.
pub(super) struct DirectoryColorAddPicker {
button: ViewHandle<ActionButton>,
dropdown: ViewHandle<FilterableDropdown<DirectoryColorAddPickerAction>>,
footer_mouse_state: MouseStateHandle,
has_dropdown_items: bool,
/// Inputs used for the last `refresh_items` computation.
/// Used to short-circuit `refresh_items` when nothing relevant has changed,
/// so noisy events like `SyncStateUpdated` / `IndexMetadataUpdated` don't pay
/// for per-path `exists()` + `canonicalize()` on every fire.
cached_inputs: Option<RefreshCacheKey>,
}
/// Cheap-to-compute inputs to `refresh_items` used to skip the expensive
/// filesystem checks inside `compute_candidate_paths` when none of these
/// inputs have changed since the last refresh.
#[derive(PartialEq, Eq)]
struct RefreshCacheKey {
indexed_paths: HashSet<PathBuf>,
persisted_paths: HashSet<PathBuf>,
existing: DirectoryTabColors,
}
#[derive(Debug, Clone, PartialEq)]
pub(super) enum DirectoryColorAddPickerAction {
Select(PathBuf),
AddNewDirectory,
}
pub(super) enum DirectoryColorAddPickerEvent {
Selected(PathBuf),
RequestAddFromFilePicker,
}
impl DirectoryColorAddPicker {
pub(super) fn new(ctx: &mut ViewContext<Self>) -> Self {
ctx.subscribe_to_model(&CodebaseIndexManager::handle(ctx), |me, _, event, ctx| {
// Refresh for any event that may change the set of indexed codebase paths or
// persisted workspaces: new index created, sync state updated (which covers
// `index_directory`), indices removed, or index metadata updated (which covers
// workspaces persisted via `PersistedWorkspace::handle_index_metadata_event`
// without a `WorkspaceAdded` event). Refresh is idempotent thanks to the
// cache in `refresh_items`, so the noisier events (`Modified`/`Queried`) are
// cheap when nothing relevant has changed.
match event {
CodebaseIndexManagerEvent::NewIndexCreated
| CodebaseIndexManagerEvent::SyncStateUpdated
| CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata { .. }
| CodebaseIndexManagerEvent::IndexMetadataUpdated { .. } => {
me.refresh_items(ctx);
}
CodebaseIndexManagerEvent::RetrievalRequestCompleted { .. }
| CodebaseIndexManagerEvent::RetrievalRequestFailed { .. } => {}
}
});
ctx.subscribe_to_model(&PersistedWorkspace::handle(ctx), |me, _, event, ctx| {
if let PersistedWorkspaceEvent::WorkspaceAdded { .. } = event {
me.refresh_items(ctx);
}
});
ctx.subscribe_to_model(&TabSettings::handle(ctx), |me, _, event, ctx| {
if let TabSettingsChangedEvent::DirectoryTabColors { .. } = event {
me.refresh_items(ctx);
}
});
let button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new(BUTTON_LABEL, SecondaryTheme)
.with_icon(icons::Icon::Plus)
.on_click(|ctx| {
ctx.dispatch_typed_action(DirectoryColorAddPickerAction::AddNewDirectory);
})
});
let dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = FilterableDropdown::new(ctx);
dropdown.set_top_bar_max_width(MENU_WIDTH);
dropdown.set_menu_width(MENU_WIDTH, ctx);
dropdown.set_menu_header_to_static(BUTTON_LABEL);
dropdown
});
let mut picker = Self {
button,
dropdown,
footer_mouse_state: MouseStateHandle::default(),
has_dropdown_items: false,
cached_inputs: None,
};
let mouse_state = picker.footer_mouse_state.clone();
picker.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_footer(
move |app| {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let is_hovered = mouse_state.lock().unwrap().is_hovered();
let bg = if is_hovered {
theme.accent_button_color()
} else {
theme.surface_2()
};
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size();
let text_color = theme.main_text_color(bg);
let border_fill = theme.outline();
let mouse_state_clone = mouse_state.clone();
Hoverable::new(mouse_state_clone, move |_| {
ConstrainedBox::new(
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new_inline(
ADD_DIRECTORY_LABEL,
font_family,
font_size,
)
.with_color(text_color.into())
.finish(),
)
.finish(),
)
.with_horizontal_padding(8.)
.with_vertical_padding(6.)
.with_background(bg)
.with_border(Border::top(1.).with_border_fill(border_fill))
.finish(),
)
.with_width(MENU_WIDTH)
.finish()
})
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(DirectoryColorAddPickerAction::AddNewDirectory);
})
.with_cursor(Cursor::PointingHand)
.finish()
},
ctx,
);
});
picker.refresh_items(ctx);
picker
}
fn refresh_items(&mut self, ctx: &mut ViewContext<Self>) {
let indexed_paths: HashSet<PathBuf> = CodebaseIndexManager::as_ref(ctx)
.get_codebase_paths()
.cloned()
.collect();
let persisted_paths: HashSet<PathBuf> = PersistedWorkspace::as_ref(ctx)
.workspaces()
.map(|ws| ws.path)
.collect();
let existing = TabSettings::as_ref(ctx)
.directory_tab_colors
.value()
.clone();
let cache_key = RefreshCacheKey {
indexed_paths,
persisted_paths,
existing,
};
if self.cached_inputs.as_ref() == Some(&cache_key) {
return;
}
let candidates = compute_candidate_paths(
cache_key.indexed_paths.iter().cloned(),
cache_key.persisted_paths.iter().cloned(),
&cache_key.existing,
|p| p.exists(),
);
let home_dir =
dirs::home_dir().and_then(|home_dir| home_dir.to_str().map(|s| s.to_owned()));
let items: Vec<DropdownItem<DirectoryColorAddPickerAction>> = candidates
.into_iter()
.map(|path| {
let label =
user_friendly_path(&path.to_string_lossy(), home_dir.as_deref()).to_string();
DropdownItem::new(label, DirectoryColorAddPickerAction::Select(path))
})
.collect();
self.cached_inputs = Some(cache_key);
self.has_dropdown_items = !items.is_empty();
let has_dropdown_items = self.has_dropdown_items;
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(items, ctx);
if !has_dropdown_items {
dropdown.close(ctx);
}
});
ctx.notify();
}
}
impl Entity for DirectoryColorAddPicker {
type Event = DirectoryColorAddPickerEvent;
}
impl View for DirectoryColorAddPicker {
fn ui_name() -> &'static str {
"DirectoryColorAddPicker"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
if self.has_dropdown_items {
ChildView::new(&self.dropdown).finish()
} else {
ChildView::new(&self.button).finish()
}
}
}
impl TypedActionView for DirectoryColorAddPicker {
type Action = DirectoryColorAddPickerAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
DirectoryColorAddPickerAction::Select(path) => {
// Don't close the dropdown here: the FilterableDropdown is
// mid-update while this handler runs, and it closes itself
// after dispatch. See `FilterableDropdown::select_action_and_close`.
ctx.emit(DirectoryColorAddPickerEvent::Selected(path.clone()));
}
DirectoryColorAddPickerAction::AddNewDirectory => {
// Footer clicks dispatch via `EventContext` (a deferred effect),
// so the dropdown is not mid-update when this handler runs.
// The fallback button also dispatches here, so closing first is harmless.
self.dropdown
.update(ctx, |dropdown, ctx| dropdown.close(ctx));
ctx.emit(DirectoryColorAddPickerEvent::RequestAddFromFilePicker);
}
}
}
}
/// Canonicalizes `path` using the same fallback logic that [`DirectoryTabColors::with_color`]
/// uses, so candidate keys line up with the keys stored in the setting.
fn canonical_key(path: &Path) -> String {
path.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string()
}
/// Computes the set of directory paths that should be offered in the add-directory dropdown.
///
/// Candidates are the union of indexed codebase paths and persisted workspace
/// paths. An entry is filtered out if:
/// - its canonical key is already a key in `existing` with a value other than
/// [`DirectoryTabColor::Suppressed`] (those are already in the visible list), or
/// - `path_exists` returns `false` for the path.
///
/// Entries keyed as `Suppressed` are intentionally kept so the user can re-add
/// a previously removed directory.
///
/// The result is deduped by canonical key and sorted alphabetically by that key
/// so it matches the order of the visible colors list rendered below the picker.
fn compute_candidate_paths(
indexed_paths: impl IntoIterator<Item = PathBuf>,
persisted_paths: impl IntoIterator<Item = PathBuf>,
existing: &DirectoryTabColors,
path_exists: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
let mut seen_keys = HashSet::new();
let mut candidates: Vec<(String, PathBuf)> = Vec::new();
for path in indexed_paths.into_iter().chain(persisted_paths.into_iter()) {
if !path_exists(&path) {
continue;
}
let key = canonical_key(&path);
if let Some(existing_color) = existing.0.get(&key) {
if !matches!(existing_color, DirectoryTabColor::Suppressed) {
continue;
}
}
if seen_keys.insert(key.clone()) {
candidates.push((key, path));
}
}
candidates.sort_by(|(a, _), (b, _)| a.cmp(b));
candidates.into_iter().map(|(_, path)| path).collect()
}
#[cfg(test)]
#[path = "directory_color_add_picker_tests.rs"]
mod tests;
@@ -0,0 +1,137 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use warp_core::ui::theme::AnsiColorIdentifier;
use super::compute_candidate_paths;
use crate::workspace::tab_settings::{DirectoryTabColor, DirectoryTabColors};
fn colors(entries: &[(&str, DirectoryTabColor)]) -> DirectoryTabColors {
let map: HashMap<String, DirectoryTabColor> = entries
.iter()
.map(|(path, color)| ((*path).to_string(), *color))
.collect();
DirectoryTabColors(map)
}
fn all_exist(_: &Path) -> bool {
true
}
#[test]
fn test_union_dedupes_across_sources() {
let indexed = vec![PathBuf::from("/nonexistent/repo_a")];
let persisted = vec![PathBuf::from("/nonexistent/repo_a")];
let existing = DirectoryTabColors::default();
let candidates = compute_candidate_paths(indexed, persisted, &existing, all_exist);
assert_eq!(candidates, vec![PathBuf::from("/nonexistent/repo_a")]);
}
#[test]
fn test_filters_out_existing_non_suppressed_entries() {
let indexed = vec![
PathBuf::from("/nonexistent/unassigned"),
PathBuf::from("/nonexistent/colored"),
PathBuf::from("/nonexistent/fresh"),
];
let existing = colors(&[
("/nonexistent/unassigned", DirectoryTabColor::Unassigned),
(
"/nonexistent/colored",
DirectoryTabColor::Color(AnsiColorIdentifier::Red),
),
]);
let candidates = compute_candidate_paths(indexed, Vec::<PathBuf>::new(), &existing, all_exist);
assert_eq!(candidates, vec![PathBuf::from("/nonexistent/fresh")]);
}
#[test]
fn test_retains_suppressed_entries_as_candidates() {
let indexed = vec![PathBuf::from("/nonexistent/suppressed_repo")];
let existing = colors(&[(
"/nonexistent/suppressed_repo",
DirectoryTabColor::Suppressed,
)]);
let candidates = compute_candidate_paths(indexed, Vec::<PathBuf>::new(), &existing, all_exist);
assert_eq!(
candidates,
vec![PathBuf::from("/nonexistent/suppressed_repo")]
);
}
#[test]
fn test_non_existent_paths_are_dropped() {
let indexed = vec![
PathBuf::from("/nonexistent/a"),
PathBuf::from("/nonexistent/b"),
];
let existing = DirectoryTabColors::default();
let candidates = compute_candidate_paths(indexed, Vec::<PathBuf>::new(), &existing, |p| {
p == Path::new("/nonexistent/b")
});
assert_eq!(candidates, vec![PathBuf::from("/nonexistent/b")]);
}
#[test]
fn test_worktree_paths_are_kept() {
let indexed = vec![
PathBuf::from("/users/alice/.warp-dev/worktrees/warp-internal/feature_a"),
PathBuf::from("/users/alice/.warp-dev/worktrees/warp-internal/feature_b"),
PathBuf::from("/users/alice/code/primary-repo"),
];
let existing = DirectoryTabColors::default();
let candidates = compute_candidate_paths(indexed, Vec::<PathBuf>::new(), &existing, all_exist);
assert_eq!(
candidates,
vec![
PathBuf::from("/users/alice/.warp-dev/worktrees/warp-internal/feature_a"),
PathBuf::from("/users/alice/.warp-dev/worktrees/warp-internal/feature_b"),
PathBuf::from("/users/alice/code/primary-repo"),
]
);
}
#[test]
fn test_results_are_sorted_alphabetically_by_canonical_key() {
let indexed = vec![
PathBuf::from("/nonexistent/zulu"),
PathBuf::from("/nonexistent/alpha"),
];
let persisted = vec![PathBuf::from("/nonexistent/mango")];
let existing = DirectoryTabColors::default();
let candidates = compute_candidate_paths(indexed, persisted, &existing, all_exist);
assert_eq!(
candidates,
vec![
PathBuf::from("/nonexistent/alpha"),
PathBuf::from("/nonexistent/mango"),
PathBuf::from("/nonexistent/zulu"),
]
);
}
#[test]
fn test_empty_inputs_produce_empty_output() {
let existing = DirectoryTabColors::default();
let candidates = compute_candidate_paths(
Vec::<PathBuf>::new(),
Vec::<PathBuf>::new(),
&existing,
all_exist,
);
assert!(candidates.is_empty());
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{
Border, Container, CornerRadius, DispatchEventResult, EventHandler, Flex,
MainAxisAlignment, MouseStateHandle, ParentElement as _, Radius, Text,
},
fonts::{Properties, Weight},
platform::Cursor,
AppContext, BlurContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use crate::editor::EditorView;
use super::EnvironmentsPageAction;
pub struct NewEnvironmentButtonView {
trigger_mouse_state: MouseStateHandle,
search_editor: ViewHandle<EditorView>,
self_handle: WeakViewHandle<Self>,
}
#[derive(Debug, Clone)]
pub enum NewEnvironmentButtonAction {
OpenSelector,
FocusSearch,
}
impl NewEnvironmentButtonView {
pub fn new(search_editor: ViewHandle<EditorView>, ctx: &mut ViewContext<Self>) -> Self {
Self {
trigger_mouse_state: Default::default(),
search_editor,
self_handle: ctx.handle(),
}
}
fn is_focused(&self, app: &AppContext) -> bool {
self.self_handle
.upgrade(app)
.is_some_and(|v| v.is_focused(app))
}
}
impl Entity for NewEnvironmentButtonView {
type Event = ();
}
impl TypedActionView for NewEnvironmentButtonView {
type Action = NewEnvironmentButtonAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
NewEnvironmentButtonAction::OpenSelector => {
ctx.dispatch_typed_action(
&EnvironmentsPageAction::OpenEnvironmentSetupModeSelector,
);
}
NewEnvironmentButtonAction::FocusSearch => {
ctx.focus(&self.search_editor);
}
}
}
}
impl View for NewEnvironmentButtonView {
fn ui_name() -> &'static str {
"NewEnvironmentButton"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.notify();
}
}
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
if blur_ctx.is_self_blurred() {
ctx.notify();
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let is_focused = self.is_focused(app);
let trigger = {
warpui::elements::Hoverable::new(self.trigger_mouse_state.clone(), move |s| {
let is_hovered = s.is_hovered();
let background = if is_hovered || is_focused {
Some(theme.surface_3())
} else {
None
};
let row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_spacing(4.)
.with_child(
Text::new(
"New environment",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_style(Properties::default().weight(Weight::Medium))
.with_color(theme.active_ui_text_color().into())
.finish(),
);
let mut container = Container::new(row.finish())
.with_horizontal_padding(12.)
.with_vertical_padding(6.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_border(Border::all(1.).with_border_fill(theme.surface_3()));
if let Some(bg) = background {
container = container.with_background(bg);
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(NewEnvironmentButtonAction::OpenSelector);
})
.finish()
};
EventHandler::new(trigger)
.on_keydown(move |ctx, _app, keystroke| {
if keystroke.is_shift_tab() {
ctx.dispatch_typed_action(NewEnvironmentButtonAction::FocusSearch);
DispatchEventResult::StopPropagation
} else if keystroke.is_unmodified_enter() {
ctx.dispatch_typed_action(NewEnvironmentButtonAction::OpenSelector);
DispatchEventResult::StopPropagation
} else {
DispatchEventResult::PropagateToParent
}
})
.finish()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,829 @@
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::execution_profiles::profiles::{
AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId,
};
use crate::ai::execution_profiles::{
ActionPermission, AskUserQuestionPermission, WriteToPtyPermission,
};
use crate::ai::llms::LLMPreferences;
use crate::appearance::Appearance;
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::settings::AISettings;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
use crate::TemplatableMCPServerManager;
use std::path::PathBuf;
use uuid::Uuid;
use warp_core::features::FeatureFlag;
use warpui::elements::ParentElement;
use warpui::SingletonEntity;
use warpui::{
elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize,
Shrinkable, Text, Wrap,
},
fonts::{Properties, Weight},
AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle,
};
#[derive(Debug, Clone)]
pub enum ExecutionProfileViewAction {
EditProfile,
}
pub enum ExecutionProfileViewEvent {
EditProfile,
}
pub struct ExecutionProfileView {
profile_id: ClientProfileId,
edit_button: ViewHandle<ActionButton>,
}
impl ExecutionProfileView {
pub fn new(profile_id: ClientProfileId, ctx: &mut ViewContext<Self>) -> Self {
ctx.subscribe_to_model(&AIExecutionProfilesModel::handle(ctx), |me, _, event, ctx| {
if matches!(event, AIExecutionProfilesModelEvent::ProfileUpdated(profile_id) if *profile_id == me.profile_id) {
ctx.notify();
}
});
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |_me, _, _, ctx| {
ctx.notify();
});
let edit_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Edit", SecondaryTheme)
.with_icon(Icon::Pencil)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(ExecutionProfileViewAction::EditProfile);
})
});
let is_any_ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
edit_button.update(ctx, |button, ctx| {
button.set_disabled(!is_any_ai_enabled, ctx);
});
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, _, ctx| {
let is_any_ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
me.edit_button.update(ctx, |button, ctx| {
button.set_disabled(!is_any_ai_enabled, ctx);
});
ctx.notify();
});
Self {
profile_id,
edit_button,
}
}
}
impl Entity for ExecutionProfileView {
type Event = ExecutionProfileViewEvent;
}
impl View for ExecutionProfileView {
fn ui_name() -> &'static str {
"ExecutionProfileView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let is_any_ai_enabled = AISettings::as_ref(app).is_any_ai_enabled(app);
let permissions = BlocklistAIPermissions::as_ref(app);
let profile = permissions.permissions_profile_for_id(app, self.profile_id);
let llm_preferences = LLMPreferences::as_ref(app);
let base_model = profile
.base_model
.as_ref()
.and_then(|id| llm_preferences.get_llm_info(id))
.map(|info| info.display_name.clone())
.unwrap_or_else(|| {
llm_preferences
.get_default_base_model()
.display_name
.clone()
});
let cli_agent_model = profile
.cli_agent_model
.as_ref()
.and_then(|id| llm_preferences.get_llm_info(id))
.map(|info| info.display_name.clone())
.unwrap_or_else(|| "Auto".to_string());
let computer_use_model = profile
.computer_use_model
.as_ref()
.and_then(|id| llm_preferences.get_llm_info(id))
.map(|info| info.display_name.clone())
.unwrap_or_else(|| "Auto".to_string());
Container::new(
Flex::column()
.with_child(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new(profile.display_name(), appearance.ui_font_family(), 14.)
.with_style(Properties::default().weight(Weight::Medium))
.with_color(if is_any_ai_enabled {
appearance.theme().active_ui_text_color().into()
} else {
appearance.theme().disabled_ui_text_color().into()
})
.finish(),
)
.with_child(self.edit_button.as_ref(app).render(app))
.finish(),
)
.with_child({
let mut model_flex = Flex::column();
model_flex.add_child(
Container::new(
Text::new("MODELS", appearance.ui_font_family(), 10.)
.with_color(appearance.theme().disabled_ui_text_color().into())
.finish(),
)
.with_margin_bottom(8.)
.finish(),
);
model_flex.add_child(with_standard_vertical_margin(
render_model_line_with_icon(
Icon::Lightning,
"Base model:",
base_model,
appearance,
is_any_ai_enabled,
),
));
model_flex.add_child(with_standard_vertical_margin(
render_model_line_with_icon(
Icon::Terminal,
"Full terminal use:",
cli_agent_model,
appearance,
is_any_ai_enabled,
),
));
if FeatureFlag::LocalComputerUse.is_enabled() {
model_flex.add_child(with_standard_vertical_margin(
render_model_line_with_icon(
Icon::Laptop,
"Computer use:",
computer_use_model,
appearance,
is_any_ai_enabled,
),
));
}
Container::new(model_flex.finish())
.with_margin_top(16.)
.with_margin_bottom(8.)
.finish()
})
.with_child(
Container::new({
let mut permissions_column = Flex::column()
.with_child(
Container::new(
Text::new("PERMISSIONS", appearance.ui_font_family(), 10.)
.with_color(
appearance.theme().disabled_ui_text_color().into(),
)
.finish(),
)
.with_margin_bottom(8.)
.finish(),
)
.with_child(with_standard_vertical_margin(
render_action_permission_line_with_icon(
Icon::Code2,
"Apply code diffs:",
&profile.apply_code_diffs,
appearance,
is_any_ai_enabled,
),
))
.with_child(with_standard_vertical_margin(
render_action_permission_line_with_icon(
Icon::Notebook,
"Read files:",
&profile.read_files,
appearance,
is_any_ai_enabled,
),
));
if profile.read_files == ActionPermission::AlwaysAsk
|| profile.read_files == ActionPermission::AgentDecides
{
permissions_column.add_child(render_directory_allowlist(
&profile,
appearance,
is_any_ai_enabled,
));
}
permissions_column.add_child(with_standard_vertical_margin(
render_action_permission_line_with_icon(
Icon::Terminal,
"Execute commands:",
&profile.execute_commands,
appearance,
is_any_ai_enabled,
),
));
match profile.execute_commands {
ActionPermission::AlwaysAllow => {
permissions_column.add_child(render_command_denylist(
&profile,
appearance,
is_any_ai_enabled,
));
}
ActionPermission::AlwaysAsk => {
permissions_column.add_child(render_command_allowlist(
&profile,
appearance,
is_any_ai_enabled,
));
}
ActionPermission::AgentDecides | ActionPermission::Unknown => {
permissions_column.add_child(render_command_allowlist(
&profile,
appearance,
is_any_ai_enabled,
));
permissions_column.add_child(render_command_denylist(
&profile,
appearance,
is_any_ai_enabled,
));
}
}
permissions_column.add_child(with_standard_vertical_margin(
render_write_to_pty_permission_line_with_icon(
Icon::Workflow,
"Interact with running commands:",
&profile.write_to_pty,
appearance,
is_any_ai_enabled,
),
));
if FeatureFlag::LocalComputerUse.is_enabled() {
permissions_column.add_child(with_standard_vertical_margin(
render_computer_use_permission_line_with_icon(
Icon::Laptop,
"Computer use:",
&profile.computer_use,
appearance,
is_any_ai_enabled,
),
));
}
permissions_column.add_child(with_standard_vertical_margin(
render_ask_user_question_permission_line_with_icon(
Icon::MessageText,
"Ask questions:",
&profile.ask_user_question,
appearance,
is_any_ai_enabled,
),
));
permissions_column.add_child(with_standard_vertical_margin(
render_action_permission_line_with_icon(
Icon::Dataflow,
"Call MCP servers:",
&profile.mcp_permissions,
appearance,
is_any_ai_enabled,
),
));
match profile.mcp_permissions {
ActionPermission::AlwaysAllow => {
permissions_column.add_child(render_mcp_denylist(
&profile,
appearance,
app,
is_any_ai_enabled,
));
}
ActionPermission::AlwaysAsk => {
permissions_column.add_child(render_mcp_allowlist(
&profile,
appearance,
app,
is_any_ai_enabled,
));
}
ActionPermission::AgentDecides | ActionPermission::Unknown => {
permissions_column.add_child(render_mcp_allowlist(
&profile,
appearance,
app,
is_any_ai_enabled,
));
permissions_column.add_child(render_mcp_denylist(
&profile,
appearance,
app,
is_any_ai_enabled,
));
}
}
if FeatureFlag::WebSearchUI.is_enabled() {
permissions_column.add_child(with_standard_vertical_margin(
render_bool_permission_line_with_icon(
Icon::Globe,
"Call web tools:",
profile.web_search_enabled,
appearance,
is_any_ai_enabled,
),
));
}
permissions_column.add_child(with_standard_vertical_margin(
render_bool_permission_line_with_icon(
Icon::Compass,
"Auto-sync plans to Warp Drive:",
profile.autosync_plans_to_warp_drive,
appearance,
is_any_ai_enabled,
),
));
permissions_column.finish()
})
.with_margin_top(16.)
.with_margin_bottom(8.)
.finish(),
)
.finish(),
)
.with_background(appearance.theme().surface_2())
.with_border(
warpui::elements::Border::new(1.).with_border_fill(appearance.theme().outline()),
)
.with_corner_radius(warpui::elements::CornerRadius::with_all(
warpui::elements::Radius::Pixels(4.),
))
.with_horizontal_padding(16.)
.with_vertical_padding(12.)
.finish()
}
}
impl TypedActionView for ExecutionProfileView {
type Action = ExecutionProfileViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ExecutionProfileViewAction::EditProfile => {
ctx.emit(ExecutionProfileViewEvent::EditProfile);
}
}
}
}
fn render_chips_row<I, S>(
items: I,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element>
where
I: IntoIterator<Item = S>,
S: ToString,
{
let items_vec: Vec<String> = items.into_iter().map(|item| item.to_string()).collect();
if items_vec.is_empty() {
return Container::new(
Text::new("None", appearance.ui_font_family(), 12.)
.with_color(appearance.theme().disabled_ui_text_color().into())
.finish(),
)
.finish();
}
Wrap::row()
.with_run_spacing(4.)
.with_children(
items_vec
.into_iter()
.map(|item| {
Container::new(
Container::new(
Text::new(item, appearance.ui_font_family(), 11.)
.with_color(if is_ai_enabled {
appearance.theme().active_ui_text_color().into()
} else {
appearance.theme().disabled_ui_text_color().into()
})
.finish(),
)
.with_background(appearance.theme().surface_2())
.with_border(
warpui::elements::Border::all(1.)
.with_border_fill(appearance.theme().outline()),
)
.with_corner_radius(warpui::elements::CornerRadius::with_all(
warpui::elements::Radius::Pixels(3.),
))
.with_horizontal_padding(6.)
.with_vertical_padding(2.)
.finish(),
)
.with_margin_right(4.)
.finish()
})
.collect::<Vec<_>>(),
)
.finish()
}
fn render_allowlist_denylist_row(
icon: Icon,
label: String,
items: &[String],
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Container::new(
ConstrainedBox::new(
icon.to_warpui_icon(if is_ai_enabled {
appearance
.theme()
.sub_text_color(appearance.theme().surface_1())
} else {
appearance.theme().disabled_ui_text_color()
})
.finish(),
)
.with_width(12.)
.with_height(12.)
.finish(),
)
.with_margin_right(6.)
.finish(),
)
.with_child(
Container::new(
Text::new(label, appearance.ui_font_family(), 12.)
.with_color(if is_ai_enabled {
appearance
.theme()
.sub_text_color(appearance.theme().surface_1())
.into()
} else {
appearance.theme().disabled_ui_text_color().into()
})
.finish(),
)
.with_margin_right(8.)
.finish(),
)
.with_child(
Shrinkable::new(1., render_chips_row(items, appearance, is_ai_enabled)).finish(),
)
.finish(),
)
.with_margin_left(8.)
.with_border(warpui::elements::Border::left(1.).with_border_fill(appearance.theme().outline()))
.with_padding_left(8.)
.finish()
}
fn render_pathbuf_allowlist_row(
icon: Icon,
label: String,
items: &[PathBuf],
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let items_str: Vec<String> = items.iter().map(|p| p.display().to_string()).collect();
render_allowlist_denylist_row(icon, label, &items_str, appearance, is_ai_enabled)
}
fn render_command_predicate_row(
icon: Icon,
label: String,
items: &[crate::settings::AgentModeCommandExecutionPredicate],
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let items_str: Vec<String> = items.iter().map(|c| c.to_string()).collect();
render_allowlist_denylist_row(icon, label, &items_str, appearance, is_ai_enabled)
}
fn render_mcp_uuid_row(
icon: Icon,
label: String,
uuids: &[Uuid],
appearance: &Appearance,
app: &AppContext,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let items_str: Vec<String> = uuids
.iter()
.filter_map(|uuid| TemplatableMCPServerManager::get_mcp_name(uuid, app))
.collect();
render_allowlist_denylist_row(icon, label, &items_str, appearance, is_ai_enabled)
}
fn with_standard_vertical_margin(element: Box<dyn Element>) -> Box<dyn Element> {
Container::new(element)
.with_margin_top(4.)
.with_margin_bottom(4.)
.finish()
}
fn render_model_line_with_icon(
icon: Icon,
label: impl Into<String>,
model_name: impl Into<String>,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let label = label.into();
let model_name = model_name.into();
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
icon.to_warpui_icon(if is_ai_enabled {
appearance
.theme()
.sub_text_color(appearance.theme().surface_1())
} else {
appearance.theme().disabled_ui_text_color()
})
.finish(),
)
.with_width(12.)
.with_height(12.)
.finish(),
)
.with_margin_right(6.)
.finish(),
)
.with_child(
Container::new(
Text::new(label, appearance.ui_font_family(), 12.)
.with_color(if is_ai_enabled {
appearance
.theme()
.sub_text_color(appearance.theme().surface_1())
.into()
} else {
appearance.theme().disabled_ui_text_color().into()
})
.finish(),
)
.with_margin_right(8.)
.finish(),
)
.with_child(
Text::new(model_name, appearance.ui_font_family(), 12.)
.with_color(if is_ai_enabled {
appearance.theme().active_ui_text_color().into()
} else {
appearance.theme().disabled_ui_text_color().into()
})
.finish(),
)
.finish(),
)
.finish()
}
fn render_permission_line_with_icon(
icon: Icon,
label: impl Into<String>,
permission_text: impl Into<String>,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let label = label.into();
let permission_text = permission_text.into();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
icon.to_warpui_icon(if is_ai_enabled {
appearance
.theme()
.sub_text_color(appearance.theme().surface_1())
} else {
appearance.theme().disabled_ui_text_color()
})
.finish(),
)
.with_width(12.)
.with_height(12.)
.finish(),
)
.with_margin_right(6.)
.finish(),
)
.with_child(
Container::new(
Text::new(label, appearance.ui_font_family(), 12.)
.with_color(if is_ai_enabled {
appearance
.theme()
.sub_text_color(appearance.theme().surface_1())
.into()
} else {
appearance.theme().disabled_ui_text_color().into()
})
.finish(),
)
.with_margin_right(8.)
.finish(),
)
.with_child(
Text::new(permission_text, appearance.ui_font_family(), 12.)
.with_color(if is_ai_enabled {
appearance.theme().active_ui_text_color().into()
} else {
appearance.theme().disabled_ui_text_color().into()
})
.finish(),
)
.finish()
}
fn render_action_permission_line_with_icon(
icon: Icon,
label: impl Into<String>,
permission: &ActionPermission,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let permission_text = match permission {
ActionPermission::AgentDecides => "Agent decides",
ActionPermission::AlwaysAllow => "Always allow",
ActionPermission::AlwaysAsk => "Always ask",
ActionPermission::Unknown => "Unknown",
};
render_permission_line_with_icon(icon, label, permission_text, appearance, is_ai_enabled)
}
fn render_write_to_pty_permission_line_with_icon(
icon: Icon,
label: impl Into<String>,
permission: &WriteToPtyPermission,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let permission_text = match permission {
WriteToPtyPermission::AlwaysAllow => "Always allow",
WriteToPtyPermission::AlwaysAsk => "Always ask",
WriteToPtyPermission::AskOnFirstWrite => "Ask on first write",
WriteToPtyPermission::Unknown => "Unknown",
};
render_permission_line_with_icon(icon, label, permission_text, appearance, is_ai_enabled)
}
fn render_computer_use_permission_line_with_icon(
icon: Icon,
label: impl Into<String>,
permission: &crate::ai::execution_profiles::ComputerUsePermission,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let permission_text = match permission {
crate::ai::execution_profiles::ComputerUsePermission::Never
| crate::ai::execution_profiles::ComputerUsePermission::Unknown => "Never",
crate::ai::execution_profiles::ComputerUsePermission::AlwaysAsk => "Always ask",
crate::ai::execution_profiles::ComputerUsePermission::AlwaysAllow => "Always allow",
};
render_permission_line_with_icon(icon, label, permission_text, appearance, is_ai_enabled)
}
fn render_ask_user_question_permission_line_with_icon(
icon: Icon,
label: impl Into<String>,
permission: &AskUserQuestionPermission,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let permission_text = match permission {
AskUserQuestionPermission::Never => "Never ask",
AskUserQuestionPermission::AskExceptInAutoApprove | AskUserQuestionPermission::Unknown => {
"Ask unless auto-approve"
}
AskUserQuestionPermission::AlwaysAsk => "Always ask",
};
render_permission_line_with_icon(icon, label, permission_text, appearance, is_ai_enabled)
}
fn render_bool_permission_line_with_icon(
icon: Icon,
label: impl Into<String>,
enabled: bool,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let permission_text = if enabled { "On" } else { "Off" };
render_permission_line_with_icon(icon, label, permission_text, appearance, is_ai_enabled)
}
fn render_directory_allowlist(
profile: &crate::ai::execution_profiles::AIExecutionProfile,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
with_standard_vertical_margin(render_pathbuf_allowlist_row(
Icon::Check,
"Directory allowlist:".to_string(),
&profile.directory_allowlist,
appearance,
is_ai_enabled,
))
}
fn render_command_allowlist(
profile: &crate::ai::execution_profiles::AIExecutionProfile,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
with_standard_vertical_margin(render_command_predicate_row(
Icon::Check,
"Command allowlist:".to_string(),
&profile.command_allowlist,
appearance,
is_ai_enabled,
))
}
fn render_command_denylist(
profile: &crate::ai::execution_profiles::AIExecutionProfile,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
with_standard_vertical_margin(render_command_predicate_row(
Icon::SlashCircle,
"Command denylist:".to_string(),
&profile.command_denylist,
appearance,
is_ai_enabled,
))
}
fn render_mcp_allowlist(
profile: &crate::ai::execution_profiles::AIExecutionProfile,
appearance: &Appearance,
app: &AppContext,
is_ai_enabled: bool,
) -> Box<dyn Element> {
with_standard_vertical_margin(render_mcp_uuid_row(
Icon::Check,
"MCP allowlist:".to_string(),
&profile.mcp_allowlist,
appearance,
app,
is_ai_enabled,
))
}
fn render_mcp_denylist(
profile: &crate::ai::execution_profiles::AIExecutionProfile,
appearance: &Appearance,
app: &AppContext,
is_ai_enabled: bool,
) -> Box<dyn Element> {
with_standard_vertical_margin(render_mcp_uuid_row(
Icon::SlashCircle,
"MCP denylist:".to_string(),
&profile.mcp_denylist,
appearance,
app,
is_ai_enabled,
))
}
@@ -0,0 +1,415 @@
use std::{cell::RefCell, collections::HashMap};
use settings::{Setting, ToggleableSetting};
use warp_core::features::FeatureFlag;
use warpui::{
elements::{Flex, MouseStateHandle, ParentElement},
ui_components::{components::UiComponent, switch::SwitchStateHandle},
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
settings_view::settings_page::{
render_body_item, render_dropdown_item, AdditionalInfo, LocalOnlyIconState, ToggleState,
},
util::file::external_editor::{
settings::{
EditorChoice, EditorLayout, OpenCodePanelsFileEditor, OpenFileEditor, OpenFileLayout,
PreferMarkdownViewer, PreferTabbedEditorView,
},
EditorSettings, SUPPORTED_EDITORS,
},
view_components::{Dropdown, DropdownItem},
};
const TABBED_FILE_VIEWER_TOGGLE_HEADER: &str = "Group files into single editor pane";
const TABBED_FILE_VIEWER_TOGGLE_DESCRIPTION: &str = "When this setting is on, any files opened in the same tab will be automatically grouped into a single editor pane.";
#[derive(Debug, Clone)]
pub enum ExternalEditorAction {
SetEditor(EditorChoice),
SetCodePanelsEditor(EditorChoice),
SetLayout(EditorLayout),
TogglePreferMarkdownViewer,
ToggleTabbedEditorView,
OpenUrl(String),
}
pub struct ExternalEditorView {
editor_dropdown: ViewHandle<Dropdown<ExternalEditorAction>>,
code_panels_editor_dropdown: ViewHandle<Dropdown<ExternalEditorAction>>,
layout_dropdown: ViewHandle<Dropdown<ExternalEditorAction>>,
tabbed_editor_view_mouse_state: SwitchStateHandle,
prefer_markdown_viewer_switch: SwitchStateHandle,
markdown_viewer_mouse_state: MouseStateHandle,
local_only_icon_states: RefCell<HashMap<String, MouseStateHandle>>,
}
impl ExternalEditorView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let settings = EditorSettings::handle(ctx);
let editor_to_open_files = *settings.as_ref(ctx).open_file_editor;
let code_panels_editor_to_open_files = *settings.as_ref(ctx).open_code_panels_file_editor;
let layout_to_open_files = *settings.as_ref(ctx).open_file_layout;
let editor_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
Self::init_editor_dropdown(
&editor_to_open_files,
&mut dropdown,
ExternalEditorAction::SetEditor,
ctx,
);
dropdown
});
let code_panels_editor_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
Self::init_editor_dropdown(
&code_panels_editor_to_open_files,
&mut dropdown,
ExternalEditorAction::SetCodePanelsEditor,
ctx,
);
dropdown
});
let layout_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
Self::init_layout_dropdown(&layout_to_open_files, &mut dropdown, ctx);
dropdown
});
ctx.subscribe_to_model(
&EditorSettings::handle(ctx),
|me, editor_settings, _, ctx| {
me.editor_dropdown.update(ctx, |dropdown, ctx| {
let editor = *editor_settings.as_ref(ctx).open_file_editor;
Self::init_editor_dropdown(
&editor,
dropdown,
ExternalEditorAction::SetEditor,
ctx,
);
});
me.code_panels_editor_dropdown.update(ctx, |dropdown, ctx| {
let editor = *editor_settings.as_ref(ctx).open_code_panels_file_editor;
Self::init_editor_dropdown(
&editor,
dropdown,
ExternalEditorAction::SetCodePanelsEditor,
ctx,
);
});
ctx.notify()
},
);
Self {
editor_dropdown,
code_panels_editor_dropdown,
layout_dropdown,
tabbed_editor_view_mouse_state: Default::default(),
prefer_markdown_viewer_switch: Default::default(),
markdown_viewer_mouse_state: Default::default(),
local_only_icon_states: Default::default(),
}
}
fn init_layout_dropdown(
layout_to_open_files: &EditorLayout,
dropdown: &mut Dropdown<ExternalEditorAction>,
ctx: &mut ViewContext<Dropdown<ExternalEditorAction>>,
) {
let default_option_text = "Split Pane";
let default_app = DropdownItem::new(
default_option_text,
ExternalEditorAction::SetLayout(EditorLayout::SplitPane),
);
let mut items = vec![default_app];
items.push(DropdownItem::new(
"New Tab",
ExternalEditorAction::SetLayout(EditorLayout::NewTab),
));
dropdown.set_items(items, ctx);
match layout_to_open_files {
EditorLayout::SplitPane => dropdown.set_selected_by_name(default_option_text, ctx),
EditorLayout::NewTab => dropdown.set_selected_by_name("New Tab", ctx),
};
}
fn init_editor_dropdown(
editor_to_open_files: &EditorChoice,
dropdown: &mut Dropdown<ExternalEditorAction>,
mut make_action: impl FnMut(EditorChoice) -> ExternalEditorAction,
ctx: &mut ViewContext<Dropdown<ExternalEditorAction>>,
) {
let default_option_text = "Default App";
let default_app = DropdownItem::new(
default_option_text,
make_action(EditorChoice::SystemDefault),
);
let mut items = vec![default_app];
items.push(DropdownItem::new("Warp", make_action(EditorChoice::Warp)));
if FeatureFlag::AllowOpeningFileLinksUsingEditorEnv.is_enabled() {
items.push(DropdownItem::new(
"$EDITOR",
make_action(EditorChoice::EnvEditor),
));
}
for editor in SUPPORTED_EDITORS {
if editor.is_installed(ctx) {
let editor_name = format!("{editor}");
items.push(DropdownItem::new(
editor_name,
make_action(EditorChoice::ExternalEditor(*editor)),
));
}
}
dropdown.set_items(items, ctx);
match editor_to_open_files {
EditorChoice::ExternalEditor(editor) => {
dropdown.set_selected_by_name(format!("{editor}"), ctx)
}
EditorChoice::Warp => dropdown.set_selected_by_name("Warp", ctx),
EditorChoice::EnvEditor => dropdown.set_selected_by_name("$EDITOR", ctx),
EditorChoice::SystemDefault => dropdown.set_selected_by_name(default_option_text, ctx),
};
}
/// Handles [`ExternalEditorAction::SetEditor`] by updating the external editor settings.
fn set_editor(&mut self, editor: &EditorChoice, ctx: &mut ViewContext<Self>) {
EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.open_file_editor.set_value(*editor, ctx));
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "SetEditor".to_string(),
value: format!("{editor:?}")
},
ctx
);
}
fn set_code_panels_editor(&mut self, editor: &EditorChoice, ctx: &mut ViewContext<Self>) {
EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.open_code_panels_file_editor
.set_value(*editor, ctx));
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "SetCodePanelsEditor".to_string(),
value: format!("{editor:?}")
},
ctx
);
}
// Handles [`ExternalEditorAction::SetLayout`] by updating the external editor layout settings.
fn set_layout(&mut self, layout: &EditorLayout, ctx: &mut ViewContext<Self>) {
EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.open_file_layout.set_value(*layout, ctx));
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "SetLayout".to_string(),
value: format!("{layout:?}")
},
ctx
);
}
/// Handles [`ExternalEditorAction::TogglePreferMarkdownViewer`]
/// preference.
fn toggle_prefer_markdown_viewer(&mut self, ctx: &mut ViewContext<Self>) {
let new_value = EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
let new_value = settings.prefer_markdown_viewer.toggle_and_save_value(ctx);
report_if_error!(new_value);
new_value.unwrap_or(PreferMarkdownViewer::default_value())
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "TogglePreferMarkdownViewer".to_string(),
value: new_value.to_string()
},
ctx
);
}
/// Handles [`ExternalEditorAction::TogglePreferTabbedEditorView`] by updating the tabbed file viewer preference.
fn toggle_prefer_tabbed_editor_view(&mut self, ctx: &mut ViewContext<Self>) {
let new_value = EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
let new_value = settings
.prefer_tabbed_editor_view
.toggle_and_save_value(ctx);
report_if_error!(new_value);
new_value.unwrap_or(PreferTabbedEditorView::default_value())
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "ToggleTabbedEditorView".to_string(),
value: new_value.to_string()
},
ctx
);
}
}
impl Entity for ExternalEditorView {
type Event = ();
}
impl View for ExternalEditorView {
fn ui_name() -> &'static str {
"ExternalEditorView"
}
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let appearance = Appearance::as_ref(app);
let default_editor = render_dropdown_item(
appearance,
"Choose an editor to open file links",
None,
None,
LocalOnlyIconState::for_setting(
OpenFileEditor::storage_key(),
OpenFileEditor::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
None,
&self.editor_dropdown,
);
let code_panels_editor = render_dropdown_item(
appearance,
"Choose an editor to open files from the code review panel, project explorer, and global search",
None,
None,
LocalOnlyIconState::for_setting(
OpenCodePanelsFileEditor::storage_key(),
OpenCodePanelsFileEditor::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
None,
&self.code_panels_editor_dropdown,
);
let default_layout = render_dropdown_item(
appearance,
"Choose a layout to open files in Warp",
None,
None,
LocalOnlyIconState::for_setting(
OpenFileLayout::storage_key(),
OpenFileLayout::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
None,
&self.layout_dropdown,
);
let mut column = Flex::column()
.with_child(default_editor)
.with_child(code_panels_editor)
.with_child(default_layout);
if FeatureFlag::TabbedEditorView.is_enabled() {
column.add_child(render_body_item::<ExternalEditorAction>(
TABBED_FILE_VIEWER_TOGGLE_HEADER.into(),
None,
LocalOnlyIconState::for_setting(
PreferTabbedEditorView::storage_key(),
PreferTabbedEditorView::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
appearance
.ui_builder()
.switch(self.tabbed_editor_view_mouse_state.clone())
.check(
*EditorSettings::as_ref(app)
.prefer_tabbed_editor_view
.value(),
)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ExternalEditorAction::ToggleTabbedEditorView);
})
.finish(),
Some(TABBED_FILE_VIEWER_TOGGLE_DESCRIPTION.into()),
));
}
column.add_child(render_body_item::<ExternalEditorAction>(
"Open Markdown files in Warp's Markdown Viewer by default".to_string(),
Some(AdditionalInfo {
mouse_state: self.markdown_viewer_mouse_state.clone(),
on_click_action: Some(ExternalEditorAction::OpenUrl(
"https://docs.warp.dev/terminal/more-features/markdown-viewer".to_string(),
)),
secondary_text: None,
tooltip_override_text: None,
}),
LocalOnlyIconState::for_setting(
PreferMarkdownViewer::storage_key(),
PreferMarkdownViewer::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
appearance
.ui_builder()
.switch(self.prefer_markdown_viewer_switch.clone())
.check(*EditorSettings::as_ref(app).prefer_markdown_viewer.value())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ExternalEditorAction::TogglePreferMarkdownViewer);
})
.finish(),
None,
));
column.finish()
}
}
impl TypedActionView for ExternalEditorView {
type Action = ExternalEditorAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ExternalEditorAction::SetEditor(editor) => self.set_editor(editor, ctx),
ExternalEditorAction::SetCodePanelsEditor(editor) => {
self.set_code_panels_editor(editor, ctx)
}
ExternalEditorAction::SetLayout(layout) => self.set_layout(layout, ctx),
ExternalEditorAction::TogglePreferMarkdownViewer => {
self.toggle_prefer_markdown_viewer(ctx)
}
ExternalEditorAction::ToggleTabbedEditorView => {
self.toggle_prefer_tabbed_editor_view(ctx);
}
ExternalEditorAction::OpenUrl(url) => {
ctx.open_url(url.as_str());
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
pub mod undo_close;
pub use undo_close::UndoCloseView;
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
pub mod external_editor;
pub use external_editor::ExternalEditorView;
}
}
cfg_if::cfg_if! {
if #[cfg(feature = "local_tty")] {
pub mod startup_shell;
pub use startup_shell::StartupShellView;
pub mod working_directory;
pub use working_directory::WorkingDirectoryView;
}
}
@@ -0,0 +1,274 @@
use warpui::{
elements::{CrossAxisAlignment, Fill, Flex, ParentElement, Shrinkable},
presenter::ChildView,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{EditorView, Event, SingleLineEditorOptions, TextOptions},
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
terminal::{
available_shells::{AvailableShell, AvailableShells},
local_tty::shell::is_valid_path_or_command_for_supported_shell,
session_settings::{SessionSettings, SessionSettingsChangedEvent},
},
view_components::{dropdown::TOP_MENU_BAR_HEIGHT, Dropdown, DropdownItem},
};
/// A view for configuring the initial shell for new sessions. This can be the
/// user's login shell, the default installed version of zsh, bash, or fish,
/// or an arbitrary user-provided path.
pub struct StartupShellView {
/// This dropdown is for selecting between the login shell, supported shells,
/// and a custom shell.
shell_dropdown: ViewHandle<Dropdown<NewSessionShellAction>>,
/// This flags whether or not to show the custom path editor. It's toggled
/// when the user chooses different dropdown options.
should_display_editor: bool,
/// If the user chose a custom shell path, they enter it in this editor.
custom_path_editor: ViewHandle<EditorView>,
/// This holds the current validity of the user's custom shell path, for
/// drawing an error border if it's invalid.
is_custom_path_valid: bool,
}
#[derive(Debug, Clone)]
pub enum NewSessionShellAction {
/// Changes the user's startup shell to the given option. This also hides
/// the custom shell path editor if a non-custom shell was chosen.
Set(AvailableShell),
/// Displays the custom shell path editor.
ShowCustomPathInput,
}
impl NewSessionShellAction {
/// Produces a [`TelemetryEvent`] that corresponds to this UI action.
///
/// This tracks both high-level information about which shells users select
/// and when they switch to the custom path UI (so we can see if they're
/// trying to use a custom shell but are unable to).
fn telemetry_event(&self) -> TelemetryEvent {
match self {
NewSessionShellAction::Set(option) => TelemetryEvent::FeaturesPageAction {
action: "NewSessionShellOverride".to_string(),
value: option.telemetry_value(),
},
NewSessionShellAction::ShowCustomPathInput => TelemetryEvent::FeaturesPageAction {
action: "ShowCustomPathInput".to_string(),
value: String::new(),
},
}
}
}
impl StartupShellView {
/// Creates a new `StartupShellView`. The UI is initialized with the user's
/// current startup shell setting.
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let custom_shell_text = AvailableShells::handle(ctx).read(ctx, |shells, ctx| {
shells.get_user_preferred_shell(ctx).get_custom_path()
});
ctx.subscribe_to_model(&SessionSettings::handle(ctx), |me, _, event, ctx| {
if matches!(
event,
SessionSettingsChangedEvent::StartupShellOverride { .. }
) {
Self::update_dropdown_state(me.shell_dropdown.clone(), ctx);
me.maybe_update_editor_state(ctx);
}
ctx.notify()
});
let shell_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
dropdown.set_top_bar_max_width(200.);
dropdown
});
Self::update_dropdown_state(shell_dropdown.clone(), ctx);
let shell_editor = ctx.add_typed_action_view(|ctx| {
let appearance_handle = Appearance::handle(ctx);
let options = SingleLineEditorOptions {
text: TextOptions::ui_font_size(appearance_handle.as_ref(ctx)),
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("Executable path", ctx);
if let Some(shell) = custom_shell_text.as_ref() {
editor.set_buffer_text(shell, ctx);
}
editor
});
ctx.subscribe_to_view(&shell_editor, move |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
Self {
shell_dropdown,
custom_path_editor: shell_editor,
is_custom_path_valid: true,
should_display_editor: custom_shell_text.is_some(),
}
}
fn maybe_update_editor_state(&mut self, ctx: &mut ViewContext<Self>) {
let custom_shell_path = AvailableShells::handle(ctx).read(ctx, |shells, ctx| {
shells.get_user_preferred_shell(ctx).get_custom_path()
});
if let Some(custom_shell_path) = custom_shell_path {
self.should_display_editor = true;
self.custom_path_editor.update(ctx, |editor_view, ctx| {
editor_view.set_buffer_text(&custom_shell_path, ctx);
});
}
}
fn update_dropdown_state(
dropdown: ViewHandle<Dropdown<NewSessionShellAction>>,
ctx: &mut ViewContext<Self>,
) {
dropdown.update(ctx, |dropdown, ctx| {
let mut items = vec![DropdownItem::new(
"Default",
NewSessionShellAction::Set(AvailableShell::default()),
)];
let shell_to_index = AvailableShells::handle(ctx).read(ctx, |model, _| {
let mut shell_to_index = std::collections::HashMap::new();
// Iterate over each shell in the model and add it to the dropdown if it's valid.
for shell_entry in model.get_available_shells() {
items.push(DropdownItem::new(
model.display_name_for_shell(shell_entry),
NewSessionShellAction::Set(shell_entry.clone()),
));
shell_to_index.insert(shell_entry.clone(), items.len() - 1);
}
shell_to_index
});
items.push(DropdownItem::new(
"Custom",
NewSessionShellAction::ShowCustomPathInput,
));
let custom_index = items.len() - 1;
dropdown.set_items(items, ctx);
let selected_shell = AvailableShells::as_ref(ctx).get_user_preferred_shell(ctx);
let selected_index = if selected_shell.get_custom_path().is_some() {
custom_index
} else {
shell_to_index.get(&selected_shell).copied().unwrap_or(0)
};
dropdown.set_selected_by_index(selected_index, ctx);
});
}
/// This callback updates the startup shell override setting based on user
/// input. If the user hits Enter or the input loses focus, the new setting
/// is saved (they can also save it by clicking outside of the text field).
fn handle_editor_event(&mut self, event: &Event, ctx: &mut ViewContext<Self>) {
match event {
Event::Edited(_) => {
let buffer_text = self.custom_path_editor.as_ref(ctx).buffer_text(ctx);
let new_validity = is_valid_path_or_command_for_supported_shell(&buffer_text);
if new_validity != self.is_custom_path_valid {
self.is_custom_path_valid = new_validity;
ctx.notify();
}
}
Event::Blurred | Event::Enter => {
let buffer_text = self.custom_path_editor.as_ref(ctx).buffer_text(ctx);
if let Ok(shell) = AvailableShell::try_from(buffer_text.as_str()) {
self.handle_action(&NewSessionShellAction::Set(shell), ctx);
}
}
_ => (),
}
}
}
impl Entity for StartupShellView {
type Event = ();
}
impl View for StartupShellView {
fn ui_name() -> &'static str {
"StartupShellView"
}
/// Renders controls to change the default shell for new sessions.
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let theme = appearance.theme();
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
row.add_child(ChildView::new(&self.shell_dropdown).finish());
if self.should_display_editor {
let border_color: Option<Fill> = if self.is_custom_path_valid {
None
} else {
Some(crate::themes::theme::Fill::error().into())
};
row.add_child(
Shrinkable::new(
1.,
ui_builder
.text_input(self.custom_path_editor.clone())
.with_style(UiComponentStyles {
border_color,
// Make sure the editor is the same height as the dropdown it's next to.
height: Some(TOP_MENU_BAR_HEIGHT),
padding: Some(Coords::uniform(7.)),
margin: Some(Coords::default().left(8.).right(8.)),
font_size: Some(appearance.ui_font_size()),
background: Some(theme.surface_2().into()),
..Default::default()
})
.build()
.finish(),
)
.finish(),
)
}
row.finish()
}
}
impl TypedActionView for StartupShellView {
type Action = NewSessionShellAction;
/// Handles a `NewSessionShellAction`, either triggered by the shell dropdown
/// or by custom path editor events.
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
NewSessionShellAction::ShowCustomPathInput => {
self.should_display_editor = true;
ctx.notify();
}
NewSessionShellAction::Set(shell) => {
if shell.get_custom_path().is_none() && self.should_display_editor {
self.should_display_editor = false;
ctx.notify();
}
AvailableShells::handle(ctx).update(ctx, |shells, ctx| {
report_if_error!(shells.set_user_preferred_shell(shell.clone(), ctx));
});
}
}
send_telemetry_from_ctx!(action.telemetry_event(), ctx);
}
}
@@ -0,0 +1,239 @@
use std::{cell::RefCell, collections::HashMap, time::Duration};
use settings::{Setting, ToggleableSetting};
use warpui::{
elements::{
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MouseStateHandle, ParentElement,
Text,
},
ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{self, EditorView, SingleLineEditorOptions, TextOptions},
report_if_error,
settings_view::{
features_page::render_group,
settings_page::{render_body_item, LocalOnlyIconState, ToggleState},
},
undo_close::{settings::UndoCloseEnabled, UndoCloseSettings},
};
#[derive(Debug, Clone, Copy)]
pub enum Action {
ToggleUndoCloseEnabled,
UpdateGracePeriod,
}
/// A view containing settings relating to the undo close feature.
pub struct UndoCloseView {
/// State for the enable/disable toggle switch.
switch_state: SwitchStateHandle,
/// An editor for modifying the undo close grace period.
grace_period_editor: ViewHandle<EditorView>,
/// Whether or not the grace period value is valid.
is_grace_period_valid: bool,
/// State for the local only icon tooltip.
local_only_icon_states: RefCell<HashMap<String, MouseStateHandle>>,
}
impl UndoCloseView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let grace_period_editor = ctx.add_typed_action_view(|ctx| {
EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_font_size(Appearance::as_ref(ctx)),
..Default::default()
},
ctx,
)
});
ctx.subscribe_to_model(&UndoCloseSettings::handle(ctx), |me, _, _, ctx| {
// Update the value of the grace period input to match the new setting
me.grace_period_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(
&format!(
"{}",
UndoCloseSettings::handle(ctx)
.as_ref(ctx)
.grace_period
.as_secs_f32()
),
ctx,
);
});
ctx.notify()
});
ctx.subscribe_to_view(&grace_period_editor, move |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
let grace_period = UndoCloseSettings::as_ref(ctx).grace_period.as_secs();
grace_period_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(&grace_period.to_string(), ctx);
});
Self {
switch_state: Default::default(),
local_only_icon_states: Default::default(),
grace_period_editor,
is_grace_period_valid: true,
}
}
/// This callback updates the undo close setting based on user input. If the
/// user hits Enter or the input loses focus, the new setting is saved (they
/// can also save it by clicking outside of the text field).
fn handle_editor_event(&mut self, event: &editor::Event, ctx: &mut ViewContext<Self>) {
use editor::Event;
match event {
Event::Edited(_) => {
let buffer_text = self.grace_period_editor.as_ref(ctx).buffer_text(ctx);
let new_validity = Self::parse_grace_period(&buffer_text).is_some();
if new_validity != self.is_grace_period_valid {
self.is_grace_period_valid = new_validity;
ctx.notify();
}
}
Event::Blurred | Event::Enter => {
self.handle_action(&Action::UpdateGracePeriod, ctx);
}
_ => (),
}
}
/// Parses user-entered text into a grace period duration, returning
/// None if the text isn't a valid grace period.
fn parse_grace_period(text: &str) -> Option<Duration> {
text.parse::<u64>().ok().map(Duration::from_secs)
}
/// Renders the editor for the grace period duration.
fn render_grace_period_editor(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let border_color = if self.is_grace_period_valid {
None
} else {
Some(crate::themes::theme::Fill::error().into())
};
let editor_style = UiComponentStyles {
border_color,
width: Some(40.),
padding: Some(Coords::uniform(5.)),
background: Some(theme.surface_2().into()),
..Default::default()
};
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Container::new(
Text::new_inline(
"Grace period (seconds)",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(theme.active_ui_text_color().into())
.finish(),
)
.with_padding_right(8.5)
.finish(),
)
.with_child(
appearance
.ui_builder()
.text_input(self.grace_period_editor.clone())
.with_style(editor_style)
.build()
.finish(),
)
.finish()
}
}
impl Entity for UndoCloseView {
type Event = ();
}
impl View for UndoCloseView {
fn ui_name() -> &'static str {
"UndoCloseView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let settings = UndoCloseSettings::as_ref(app);
let enabled = *settings.enabled;
let mut column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(render_body_item::<Action>(
"Enable reopening of closed sessions".into(),
None,
LocalOnlyIconState::for_setting(
UndoCloseEnabled::storage_key(),
UndoCloseEnabled::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
ui_builder
.switch(self.switch_state.clone())
.check(enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(Action::ToggleUndoCloseEnabled);
})
.finish(),
None,
));
if enabled {
column.add_child(render_group(
[self.render_grace_period_editor(appearance)],
appearance,
));
}
column.finish()
}
}
impl TypedActionView for UndoCloseView {
type Action = Action;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut warpui::ViewContext<Self>) {
match action {
Action::ToggleUndoCloseEnabled => {
UndoCloseSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.enabled.toggle_and_save_value(ctx));
})
}
Action::UpdateGracePeriod => {
let grace_period_secs = self
.grace_period_editor
.read(ctx, |editor, ctx| editor.buffer_text(ctx));
let Some(grace_period) = Self::parse_grace_period(&grace_period_secs) else {
self.is_grace_period_valid = false;
return;
};
self.is_grace_period_valid = true;
UndoCloseSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.grace_period.set_value(grace_period, ctx));
});
}
}
}
}
@@ -0,0 +1,404 @@
use itertools::Itertools;
use warpui::{
elements::{Container, CrossAxisAlignment, Flex, ParentElement, Shrinkable},
presenter::ChildView,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
settings_view::features_page::render_group,
terminal::session_settings::*,
view_components::{dropdown::TOP_MENU_BAR_HEIGHT, Dropdown, DropdownItem},
};
#[derive(Clone, Debug)]
#[allow(clippy::enum_variant_names)]
pub enum WorkingDirectoryAction {
/// Sets the mode that should be used for all new sessions, independent of
/// source. A value of None indicates that the mode should be configured
/// per-source instead of globally (i.e.: "advanced" mode).
SetGlobalWorkingDirectoryMode(Option<WorkingDirectoryMode>),
/// Sets the mode that should be used for new sessions spawned from the
/// given source (e.g.: new tab/window/split pane).
SetPerSourceWorkingDirectoryMode(NewSessionSource, WorkingDirectoryMode),
/// Sets the path that will be used for [`WorkingDirectoryMode::CustomDir`]
/// for the given source (where None represents global configuration).
SetCustomWorkingDirectoryValue(Option<NewSessionSource>, String),
}
/// A view for configuring the initial working directory for new sessions,
/// either globally or on a per-source (new tab/window/split pane) basis.
pub struct WorkingDirectoryView {
working_directory_dropdown: ViewHandle<Dropdown<WorkingDirectoryAction>>,
working_directory_editor: ViewHandle<EditorView>,
new_window_working_directory_dropdown: ViewHandle<Dropdown<WorkingDirectoryAction>>,
new_window_working_directory_editor: ViewHandle<EditorView>,
new_tab_working_directory_dropdown: ViewHandle<Dropdown<WorkingDirectoryAction>>,
new_tab_working_directory_editor: ViewHandle<EditorView>,
split_pane_working_directory_dropdown: ViewHandle<Dropdown<WorkingDirectoryAction>>,
split_pane_working_directory_editor: ViewHandle<EditorView>,
}
impl WorkingDirectoryView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let working_directory_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
init_top_level_dropdown(&mut dropdown, ctx);
dropdown
});
let working_directory_editor = create_editor(None, ctx);
let new_window_working_directory_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
init_per_source_dropdown(&mut dropdown, NewSessionSource::Window, ctx);
dropdown
});
let new_window_working_directory_editor =
create_editor(Some(NewSessionSource::Window), ctx);
let new_tab_working_directory_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
init_per_source_dropdown(&mut dropdown, NewSessionSource::Tab, ctx);
dropdown
});
let new_tab_working_directory_editor = create_editor(Some(NewSessionSource::Tab), ctx);
let split_pane_working_directory_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
init_per_source_dropdown(&mut dropdown, NewSessionSource::SplitPane, ctx);
dropdown
});
let split_pane_working_directory_editor =
create_editor(Some(NewSessionSource::SplitPane), ctx);
ctx.subscribe_to_model(&SessionSettings::handle(ctx), |me, _, event, ctx| {
if matches!(
event,
SessionSettingsChangedEvent::WorkingDirectoryConfig { .. }
) {
me.working_directory_dropdown.update(ctx, |dropdown, ctx| {
init_top_level_dropdown(dropdown, ctx);
ctx.notify();
});
me.new_window_working_directory_dropdown
.update(ctx, |dropdown, ctx| {
init_per_source_dropdown(dropdown, NewSessionSource::Window, ctx);
ctx.notify();
});
me.new_tab_working_directory_dropdown
.update(ctx, |dropdown, ctx| {
init_per_source_dropdown(dropdown, NewSessionSource::Tab, ctx);
ctx.notify();
});
me.split_pane_working_directory_dropdown
.update(ctx, |dropdown, ctx| {
init_per_source_dropdown(dropdown, NewSessionSource::SplitPane, ctx);
ctx.notify();
});
ctx.notify();
}
});
Self {
working_directory_dropdown,
working_directory_editor,
new_window_working_directory_dropdown,
new_window_working_directory_editor,
new_tab_working_directory_dropdown,
new_tab_working_directory_editor,
split_pane_working_directory_dropdown,
split_pane_working_directory_editor,
}
}
}
impl Entity for WorkingDirectoryView {
type Event = ();
}
impl View for WorkingDirectoryView {
fn ui_name() -> &'static str {
"WorkingDirectoryView"
}
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let settings = SessionSettings::as_ref(app);
let config = &settings.working_directory_config;
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(render_row(
&self.working_directory_dropdown,
&self.working_directory_editor,
config.global.mode == WorkingDirectoryMode::CustomDir && !config.advanced_mode,
appearance,
));
if config.advanced_mode {
let items = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_children([
ui_builder.label("New window").build().finish(),
render_row(
&self.new_window_working_directory_dropdown,
&self.new_window_working_directory_editor,
config.new_window.mode == WorkingDirectoryMode::CustomDir,
appearance,
),
ui_builder.label("New tab").build().finish(),
render_row(
&self.new_tab_working_directory_dropdown,
&self.new_tab_working_directory_editor,
config.new_tab.mode == WorkingDirectoryMode::CustomDir,
appearance,
),
ui_builder.label("Split pane").build().finish(),
render_row(
&self.split_pane_working_directory_dropdown,
&self.split_pane_working_directory_editor,
config.split_pane.mode == WorkingDirectoryMode::CustomDir,
appearance,
),
])
.finish();
column.add_child(
Container::new(render_group(
[Container::new(items)
.with_margin_top(4.)
.with_margin_bottom(2.)
.finish()],
appearance,
))
.with_margin_top(8.)
.finish(),
);
}
column.finish()
}
}
impl TypedActionView for WorkingDirectoryView {
type Action = WorkingDirectoryAction;
fn handle_action(&mut self, action: &WorkingDirectoryAction, ctx: &mut ViewContext<Self>) {
use WorkingDirectoryAction::*;
match action {
SetGlobalWorkingDirectoryMode(mode) => {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.working_directory_config.update_and_save_value(
|config| {
if let Some(mode) = mode {
config.advanced_mode = false;
config.global.mode = *mode;
} else {
config.advanced_mode = true;
}
},
ctx,
));
});
send_telemetry_from_ctx!(
TelemetryEvent::InitialWorkingDirectoryConfigurationChanged {
advanced_mode_enabled: mode.is_none()
},
ctx
);
// Redraw settings in case we switched in or out of advanced mode.
ctx.notify();
}
SetPerSourceWorkingDirectoryMode(source, mode) => {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.working_directory_config.update_and_save_value(
|config| match source {
NewSessionSource::SplitPane => config.split_pane.mode = *mode,
NewSessionSource::Tab => config.new_tab.mode = *mode,
NewSessionSource::Window => config.new_window.mode = *mode,
},
ctx,
));
});
// Redraw settings in case we changed a mode to/from "custom directory".
ctx.notify();
}
SetCustomWorkingDirectoryValue(source, value) => {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.working_directory_config.update_and_save_value(
|config| match source {
Some(NewSessionSource::SplitPane) => {
config.split_pane.custom_dir.clone_from(value)
}
Some(NewSessionSource::Tab) => {
config.new_tab.custom_dir.clone_from(value)
}
Some(NewSessionSource::Window) => {
config.new_window.custom_dir.clone_from(value)
}
None => config.global.custom_dir.clone_from(value),
},
ctx,
));
});
}
}
}
}
/// Render a single row, containing a dropdown view and editor.
///
/// `show_editor` controls whether the editor is currently visible.
fn render_row(
dropdown: &ViewHandle<Dropdown<WorkingDirectoryAction>>,
editor: &ViewHandle<EditorView>,
show_editor: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
row.add_child(ChildView::new(dropdown).finish());
if show_editor {
row.add_child(
Shrinkable::new(
1.,
appearance
.ui_builder()
.text_input(editor.clone())
.with_style(UiComponentStyles {
height: Some(TOP_MENU_BAR_HEIGHT),
font_color: Some(pathfinder_color::ColorU::black()),
font_size: Some(appearance.ui_font_size()),
padding: Some(Coords::uniform(7.)),
margin: Some(Coords::default().left(8.).right(8.)),
background: Some(appearance.theme().surface_2().into()),
..Default::default()
})
.build()
.finish(),
)
.finish(),
);
}
row.finish()
}
/// Initializes the top-level dropdown (global configuration vs. advanced mode).
fn init_top_level_dropdown(
dropdown: &mut Dropdown<WorkingDirectoryAction>,
ctx: &mut ViewContext<Dropdown<WorkingDirectoryAction>>,
) {
let mut items = [
WorkingDirectoryMode::HomeDir,
WorkingDirectoryMode::PreviousDir,
WorkingDirectoryMode::CustomDir,
]
.into_iter()
.map(|mode| {
DropdownItem::new(
mode.dropdown_item_label(),
WorkingDirectoryAction::SetGlobalWorkingDirectoryMode(Some(mode)),
)
})
.collect_vec();
items.push(DropdownItem::new(
"Advanced".to_string(),
WorkingDirectoryAction::SetGlobalWorkingDirectoryMode(None),
));
let advanced_item_index = items.len() - 1;
dropdown.set_items(items, ctx);
dropdown.set_top_bar_max_width(200.);
let config = &SessionSettings::as_ref(ctx).working_directory_config;
if config.advanced_mode {
dropdown.set_selected_by_index(advanced_item_index, ctx);
} else {
dropdown.set_selected_by_name(config.global.mode.dropdown_item_label(), ctx);
}
}
/// Initializes a dropdown that relates to a particular new session source.
fn init_per_source_dropdown(
dropdown: &mut Dropdown<WorkingDirectoryAction>,
source: NewSessionSource,
ctx: &mut ViewContext<Dropdown<WorkingDirectoryAction>>,
) {
let items = [
WorkingDirectoryMode::HomeDir,
WorkingDirectoryMode::PreviousDir,
WorkingDirectoryMode::CustomDir,
]
.into_iter()
.map(|mode| {
DropdownItem::new(
mode.dropdown_item_label(),
WorkingDirectoryAction::SetPerSourceWorkingDirectoryMode(source, mode),
)
})
.collect_vec();
dropdown.set_items(items, ctx);
dropdown.set_top_bar_max_width(200.);
let config = &SessionSettings::as_ref(ctx).working_directory_config;
let source_config = match source {
NewSessionSource::SplitPane => &config.split_pane,
NewSessionSource::Tab => &config.new_tab,
NewSessionSource::Window => &config.new_window,
};
dropdown.set_selected_by_name(source_config.mode.dropdown_item_label(), ctx);
}
/// Creates a new editor view for entering a custom initial directory path.
fn create_editor(
source: Option<NewSessionSource>,
ctx: &mut ViewContext<WorkingDirectoryView>,
) -> ViewHandle<EditorView> {
let editor = {
let appearance = Appearance::as_ref(ctx);
let options = SingleLineEditorOptions {
text: TextOptions::ui_font_size(appearance),
..Default::default()
};
ctx.add_typed_action_view(|ctx| {
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("Directory path", ctx);
editor
})
};
let initial_value = {
let config = &SessionSettings::as_ref(ctx).working_directory_config;
let source_config = match source {
None => &config.global,
Some(NewSessionSource::SplitPane) => &config.split_pane,
Some(NewSessionSource::Tab) => &config.new_tab,
Some(NewSessionSource::Window) => &config.new_window,
};
source_config.custom_dir.clone()
};
editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(&initial_value, ctx);
});
let editor_handle = editor.clone();
ctx.subscribe_to_view(&editor, move |me, _, event, ctx| match event {
// If the user presses enter or focus moves out of the editor view,
// update our configuration to match the current value.
EditorEvent::Blurred | EditorEvent::Enter => {
let editor_contents = editor_handle.as_ref(ctx).buffer_text(ctx);
me.handle_action(
&WorkingDirectoryAction::SetCustomWorkingDirectoryValue(source, editor_contents),
ctx,
);
}
_ => {}
});
editor
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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();
}
}
}
}
}
+587
View File
@@ -0,0 +1,587 @@
use std::collections::HashMap;
use uuid::Uuid;
use warpui::{
elements::{ChildView, Container},
ui_components::components::{Coords, UiComponentStyles},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
ai::mcp::{
gallery::MCPGalleryManager, templatable_installation::VariableValue, FileBasedMCPManager,
TemplatableMCPServer, TemplatableMCPServerInstallation, TemplatableMCPServerManager,
},
appearance::Appearance,
cloud_object::Space,
modal::{Modal, ModalViewState},
server::cloud_objects::update_manager::InitiatedBy,
settings_view::{
mcp_servers::{
edit_page::{MCPServersEditPageView, MCPServersEditPageViewEvent},
installation_modal::{InstallationModalBody, InstallationModalBodyEvent},
list_page::{MCPServersListPageView, MCPServersListPageViewEvent},
style, ServerCardItemId,
},
settings_page::{MatchData, PageType, SettingsPageMeta, SettingsWidget},
SettingsSection,
},
view_components::DismissibleToast,
workspace::ToastStack,
};
/// Describes where an MCP install request originated.
///
/// Used to decide whether an install request is allowed to bypass the
/// installation modal. In-app gestures (gallery card click, reinstall button)
/// are implicitly confirmed by the click itself. Deeplink-triggered installs
/// are untrusted and must always route through the installation modal so the
/// user can explicitly confirm before any installation or server spawn occurs.
/// See `specs/GH686/product.md`.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum InstallOrigin {
/// Triggered by a user gesture inside Warp (gallery card click,
/// reinstall button, programmatic in-app flows, etc.).
InApp,
/// Triggered by a `warp://settings/mcp?autoinstall=...` deeplink; must be
/// gated by an explicit in-app confirmation before install or spawn.
Deeplink,
}
const PAGE_TITLE_TEXT: &str = "MCP Servers";
#[derive(Debug, Default, Copy, Clone)]
pub enum MCPServersSettingsPage {
#[default]
List,
Edit {
item_id: Option<ServerCardItemId>,
},
}
#[derive(Debug, Clone)]
pub enum MCPServersSettingsPageEvent {
ShowModal,
HideModal,
}
pub struct MCPServersSettingsPageView {
page: PageType<Self>,
current_page: MCPServersSettingsPage,
list_view: ViewHandle<MCPServersListPageView>,
edit_view: ViewHandle<MCPServersEditPageView>,
installation_modal_state: ModalViewState<Modal<InstallationModalBody>>,
}
impl MCPServersSettingsPageView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let list_view = ctx.add_typed_action_view(MCPServersListPageView::new);
ctx.subscribe_to_view(&list_view, |me, _, event, ctx| {
me.handle_list_view_event(event, ctx);
});
let edit_view = ctx.add_typed_action_view(MCPServersEditPageView::new);
ctx.subscribe_to_view(&edit_view, |me, _, event, ctx| {
me.handle_edit_view_event(event, ctx);
});
let installation_modal_body =
ctx.add_typed_action_view(|_ctx| InstallationModalBody::new());
ctx.subscribe_to_view(&installation_modal_body, |me, _, event, ctx| {
me.handle_installation_modal_body_event(event, ctx);
});
let installation_modal = ctx.add_typed_action_view(|ctx| {
Modal::new(None, installation_modal_body, ctx).with_body_style(UiComponentStyles {
padding: Some(Coords::uniform(0.)),
..Default::default()
})
});
let installation_modal_state = ModalViewState::new(installation_modal);
Self {
page: PageType::new_monolith(
MCPServersSettingsWidget::default(),
Some(PAGE_TITLE_TEXT),
true,
),
current_page: MCPServersSettingsPage::default(),
list_view,
edit_view,
installation_modal_state,
}
}
pub fn update_page(&mut self, page: MCPServersSettingsPage, ctx: &mut ViewContext<Self>) {
self.current_page = page;
if let MCPServersSettingsPage::Edit { item_id } = page {
self.edit_view.update(ctx, |edit_view, ctx| {
edit_view.set_mcp_server(item_id, ctx);
});
}
self.focus(ctx);
ctx.notify();
}
pub fn focus(&mut self, ctx: &mut ViewContext<Self>) {
match self.current_page {
MCPServersSettingsPage::List => ctx.focus(&self.list_view),
MCPServersSettingsPage::Edit { .. } => ctx.focus(&self.edit_view),
}
}
fn add_toast(&mut self, message: &str, ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::default(message.to_string()),
window_id,
ctx,
);
});
}
fn handle_log_out(
&mut self,
item_id: ServerCardItemId,
server_name: Option<String>,
ctx: &mut ViewContext<Self>,
) {
let message = match server_name {
Some(name) => format!("Successfully logged out of {name} MCP server"),
None => "Successfully logged out of MCP server".to_string(),
};
match item_id {
ServerCardItemId::TemplatableMCP(_) => {
log::error!("Logging out is not supported for template MCP servers.");
}
ServerCardItemId::TemplatableMCPInstallation(uuid) => {
TemplatableMCPServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.delete_credentials_from_secure_storage(uuid, ctx);
manager.shutdown_server(uuid, ctx);
});
self.add_toast(&message, ctx);
}
ServerCardItemId::GalleryMCP(_) => {
log::error!("Logging out is not supported for gallery MCP servers.");
}
ServerCardItemId::FileBasedMCP(uuid) => {
if let Some(installation) =
FileBasedMCPManager::as_ref(ctx).get_installation_by_uuid(uuid)
{
if let Some(hash) = installation.hash() {
TemplatableMCPServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.shutdown_server(uuid, ctx);
manager.purge_file_based_server_credentials(&vec![hash], ctx);
});
}
}
self.add_toast(&message, ctx);
}
}
}
fn start_server_installation(
&mut self,
templatable_mcp_server: TemplatableMCPServer,
instructions_in_markdown: Option<String>,
origin: InstallOrigin,
ctx: &mut ViewContext<Self>,
) {
let has_variables = !templatable_mcp_server.template.variables.is_empty();
let has_instructions = instructions_in_markdown.is_some();
let should_show_modal =
Self::should_show_install_modal(origin, has_variables, has_instructions);
if should_show_modal {
self.installation_modal_state
.view
.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.set_templatable_mcp_server(
Some(templatable_mcp_server),
instructions_in_markdown,
ctx,
)
});
});
self.installation_modal_state.open();
ctx.focus(&self.installation_modal_state.view);
ctx.emit(MCPServersSettingsPageEvent::ShowModal);
} else {
self.process_server_installation(&templatable_mcp_server, HashMap::new(), ctx);
}
ctx.notify();
}
/// Decides whether an install request should route through the installation
/// modal. Deeplink-origin requests always require explicit confirmation via
/// the modal, regardless of template shape. In-app requests keep the
/// pre-existing heuristic where the modal is only shown when the template
/// has variables or markdown instructions; the click gesture itself is the
/// user confirmation. See `specs/GH686/product.md`.
pub(crate) fn should_show_install_modal(
origin: InstallOrigin,
has_variables: bool,
has_instructions: bool,
) -> bool {
match origin {
InstallOrigin::Deeplink => true,
InstallOrigin::InApp => has_variables || has_instructions,
}
}
fn process_server_installation(
&mut self,
templatable_mcp_server: &TemplatableMCPServer,
variable_values: HashMap<String, VariableValue>,
ctx: &mut ViewContext<Self>,
) -> Option<TemplatableMCPServerInstallation> {
TemplatableMCPServerManager::handle(ctx).update(ctx, |templatable_manager, ctx| {
if templatable_manager
.get_cloud_server(templatable_mcp_server.uuid, ctx)
.is_none()
{
templatable_manager.create_templatable_mcp_server(
templatable_mcp_server.clone(),
Space::Personal,
InitiatedBy::User,
ctx,
);
}
let installation = templatable_manager.install_from_template(
templatable_mcp_server.clone(),
variable_values.clone(),
true,
ctx,
);
ctx.notify();
installation
})
}
pub fn reinstall_server(&mut self, installation_uuid: Uuid, ctx: &mut ViewContext<Self>) {
let template_uuid =
TemplatableMCPServerManager::as_ref(ctx).get_template_uuid(installation_uuid);
if let Some(template_uuid) = template_uuid {
let templatable_mcp_server =
TemplatableMCPServerManager::as_ref(ctx).get_templatable_mcp_server(template_uuid);
if let Some(templatable_mcp_server) = templatable_mcp_server {
// Reinstall is always an in-app action triggered from the edit page
// reinstall button; it must not pick up the deeplink confirmation gating.
self.start_server_installation(
templatable_mcp_server.clone(),
None,
InstallOrigin::InApp,
ctx,
);
}
}
}
/// Emits an error toast in the current window.
fn add_error_toast(&mut self, message: String, ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(DismissibleToast::error(message), window_id, ctx);
});
}
/// Auto-installs an MCP server from the gallery.
///
/// This is the single sink for `warp://settings/mcp?autoinstall=<title>`
/// deeplinks; callers must therefore treat the `autoinstall_param` as
/// untrusted input. The `autoinstall_param` is matched case-insensitively
/// against gallery titles.
///
/// Every deeplink autoinstall is routed through the installation modal and
/// requires an explicit user confirmation before any installation or
/// server spawn occurs — even for templates with no variables and no
/// markdown instructions. See `specs/GH686/product.md`.
pub fn autoinstall_from_gallery(
&mut self,
autoinstall_param: &str,
ctx: &mut ViewContext<Self>,
) {
log::info!("Received MCP deeplink autoinstall for value '{autoinstall_param}'");
// Concurrent deeplink guard: if a prior installation modal is still
// open, surface a toast and bail so we do not silently overwrite the
// current modal contents with a different template. See product
// invariant 7 in specs/GH686/product.md.
if self.installation_modal_state.is_open() {
log::warn!(
"Ignoring MCP deeplink autoinstall for '{autoinstall_param}': installation modal already open"
);
self.add_error_toast(
"Finish the current MCP install before opening another install link.".to_string(),
ctx,
);
return;
}
let autoinstall_lower = autoinstall_param.to_lowercase();
let gallery_server = MCPGalleryManager::as_ref(ctx)
.get_gallery()
.into_iter()
.find(|item| item.title().to_lowercase() == autoinstall_lower);
let Some(gallery_server) = gallery_server else {
log::warn!(
"Unrecognized autoinstall value '{autoinstall_param}': no matching gallery item found"
);
self.add_error_toast(format!("Unknown MCP server '{autoinstall_param}'"), ctx);
return;
};
// Skip if this gallery item is already installed.
let gallery_uuid = gallery_server.uuid();
let already_installed = TemplatableMCPServerManager::as_ref(ctx)
.get_installed_templatable_servers()
.values()
.any(|installation| installation.gallery_uuid() == Some(gallery_uuid));
if already_installed {
log::info!(
"Gallery MCP server '{}' is already installed, skipping autoinstall",
gallery_server.title()
);
return;
}
let instructions = gallery_server.instructions_in_markdown().cloned();
let gallery_title = gallery_server.title().to_string();
let Ok(templatable_mcp_server) = TemplatableMCPServer::try_from(gallery_server) else {
log::warn!(
"Failed to convert gallery item '{autoinstall_param}' to TemplatableMCPServer"
);
// Invariant 5 (specs/GH686/product.md): the match succeeded but the
// gallery entry cannot be turned into a valid template. Surface the
// failure to the user rather than silently returning.
self.add_error_toast(
format!("MCP server '{gallery_title}' cannot be installed from this link."),
ctx,
);
return;
};
log::info!("Opening MCP install confirmation for deeplink gallery title '{gallery_title}'");
self.start_server_installation(
templatable_mcp_server,
instructions,
InstallOrigin::Deeplink,
ctx,
);
}
fn handle_list_view_event(
&mut self,
event: &MCPServersListPageViewEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
MCPServersListPageViewEvent::Edit(mcp_item_id) => {
self.update_page(
MCPServersSettingsPage::Edit {
item_id: Some(*mcp_item_id),
},
ctx,
);
}
MCPServersListPageViewEvent::Add => {
self.update_page(MCPServersSettingsPage::Edit { item_id: None }, ctx);
}
MCPServersListPageViewEvent::LogOut(server_card_item_id, server_name) => {
self.handle_log_out(*server_card_item_id, Some(server_name.clone()), ctx);
}
MCPServersListPageViewEvent::StartInstallation {
templatable_mcp_server: template,
instructions_in_markdown,
origin,
} => {
self.start_server_installation(
template.clone(),
instructions_in_markdown.clone(),
*origin,
ctx,
);
}
MCPServersListPageViewEvent::ShowModal => {
ctx.emit(MCPServersSettingsPageEvent::ShowModal);
}
MCPServersListPageViewEvent::HideModal => {
ctx.emit(MCPServersSettingsPageEvent::HideModal);
}
}
}
fn handle_edit_view_event(
&mut self,
event: &MCPServersEditPageViewEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
MCPServersEditPageViewEvent::Back => {
self.update_page(MCPServersSettingsPage::List, ctx);
}
MCPServersEditPageViewEvent::Reinstall(template_uuid) => {
self.reinstall_server(*template_uuid, ctx);
}
MCPServersEditPageViewEvent::Delete(item_id) => {
self.list_view.update(ctx, |list_view, ctx| {
list_view.delete_server(*item_id, ctx);
});
self.update_page(MCPServersSettingsPage::List, ctx);
}
MCPServersEditPageViewEvent::LogOut(server_card_item_id, server_name) => {
self.handle_log_out(*server_card_item_id, server_name.clone(), ctx);
}
}
}
pub fn get_modal_content(&self, app: &AppContext) -> Option<Box<dyn Element>> {
if self.installation_modal_state.is_open() {
Some(self.installation_modal_state.render())
} else {
match self.current_page {
MCPServersSettingsPage::List => self
.list_view
.read(app, |list_view, _| list_view.get_modal_content()),
MCPServersSettingsPage::Edit { .. } => None,
}
}
}
fn handle_installation_modal_body_event(
&mut self,
event: &InstallationModalBodyEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
InstallationModalBodyEvent::Install(templatable_mcp_server, variable_values) => {
// Uninstall the old copy with outdated variable values
TemplatableMCPServerManager::handle(ctx).update(ctx, |manager, ctx| {
let old_installation =
manager.get_installation_by_template_uuid(templatable_mcp_server.uuid);
if let Some(old_installation) = old_installation {
let old_installation_uuid = old_installation.uuid();
manager
.delete_templatable_mcp_server_installation(old_installation_uuid, ctx);
ctx.notify();
};
});
// Install the copy with new variables
let new_installation = self.process_server_installation(
templatable_mcp_server,
variable_values.clone(),
ctx,
);
// When we re-install, the installation uuid changes, so we should load the edit page with the new installation uuid
if let Some(new_installation) = new_installation {
self.edit_view.update(ctx, |edit_page, ctx| {
edit_page.set_mcp_server(
Some(ServerCardItemId::TemplatableMCPInstallation(
new_installation.uuid(),
)),
ctx,
);
});
}
self.installation_modal_state
.view
.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.set_templatable_mcp_server(None, None, ctx)
});
});
self.installation_modal_state.close();
ctx.emit(MCPServersSettingsPageEvent::HideModal);
ctx.notify();
}
InstallationModalBodyEvent::Cancel => {
self.installation_modal_state.close();
ctx.emit(MCPServersSettingsPageEvent::HideModal);
ctx.notify();
}
}
}
}
impl Entity for MCPServersSettingsPageView {
type Event = MCPServersSettingsPageEvent;
}
impl View for MCPServersSettingsPageView {
fn ui_name() -> &'static str {
"MCPServersSettingsPageView"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
match self.current_page {
MCPServersSettingsPage::List => self.page.render(self, _app),
MCPServersSettingsPage::Edit { item_id: _ } => {
// The edit view needs to be constrained so we will render it directly
// instead of rendering inside the settings widget
Container::new(ChildView::new(&self.edit_view).finish())
.with_uniform_padding(style::PAGE_PADDING)
.finish()
}
}
}
}
impl TypedActionView for MCPServersSettingsPageView {
type Action = ();
}
impl SettingsPageMeta for MCPServersSettingsPageView {
fn section() -> SettingsSection {
SettingsSection::MCPServers
}
fn should_render(&self, _ctx: &AppContext) -> bool {
true
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
self.page.update_filter(query, ctx)
}
fn scroll_to_widget(&mut self, widget_id: &'static str) {
self.page.scroll_to_widget(widget_id)
}
fn clear_highlighted_widget(&mut self) {
self.page.clear_highlighted_widget()
}
}
#[cfg(test)]
#[path = "mcp_servers_page_tests.rs"]
mod tests;
#[derive(Default)]
pub struct MCPServersSettingsWidget {
// No state yet
}
impl SettingsWidget for MCPServersSettingsWidget {
type View = MCPServersSettingsPageView;
fn search_terms(&self) -> &str {
"mcp servers"
}
fn render(
&self,
view: &Self::View,
_appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn Element> {
// The settings widget will always return list view
// The edit view needs to be constrained so we will render it directly
// instead of rendering inside the settings widget
ChildView::new(&view.list_view).finish()
}
}
@@ -0,0 +1,60 @@
use super::{InstallOrigin, MCPServersSettingsPageView};
// These tests cover the origin-first decision tree introduced in
// specs/GH686/product.md. The underlying `start_server_installation` flow
// exercises `MCPServersSettingsPageView::should_show_install_modal`, so
// asserting on the pure predicate captures the security-critical invariants
// without standing up a full view context or MCP manager.
#[test]
fn deeplink_origin_always_shows_modal() {
// Invariant 2 (specs/GH686/product.md): every deeplink autoinstall shows
// the installation modal, even when the template has no variables and no
// markdown instructions.
assert!(MCPServersSettingsPageView::should_show_install_modal(
InstallOrigin::Deeplink,
/* has_variables */ false,
/* has_instructions */ false,
));
assert!(MCPServersSettingsPageView::should_show_install_modal(
InstallOrigin::Deeplink,
true,
false,
));
assert!(MCPServersSettingsPageView::should_show_install_modal(
InstallOrigin::Deeplink,
false,
true,
));
assert!(MCPServersSettingsPageView::should_show_install_modal(
InstallOrigin::Deeplink,
true,
true,
));
}
#[test]
fn in_app_origin_shows_modal_only_for_variables_or_instructions() {
// Invariant 9 (specs/GH686/product.md): in-app gallery clicks must keep
// today's behavior: modal only when has_variables || has_instructions.
assert!(!MCPServersSettingsPageView::should_show_install_modal(
InstallOrigin::InApp,
false,
false,
));
assert!(MCPServersSettingsPageView::should_show_install_modal(
InstallOrigin::InApp,
true,
false,
));
assert!(MCPServersSettingsPageView::should_show_install_modal(
InstallOrigin::InApp,
false,
true,
));
assert!(MCPServersSettingsPageView::should_show_install_modal(
InstallOrigin::InApp,
true,
true,
));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use pathfinder_geometry::vector::vec2f;
use warpui::{
elements::{Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle},
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{Coords, UiComponent, UiComponentStyles},
},
};
use super::{
settings_page::{MatchData, NAV_ITEM_LEFT_MARGIN},
SettingsSection,
};
/// The font size for subpage items inside an umbrella.
const SUBPAGE_FONT_SIZE: f32 = 10.;
/// Left margin for subpage items inside an umbrella (top-level margin + indent).
const SUBPAGE_LEFT_MARGIN: f32 = NAV_ITEM_LEFT_MARGIN + 12.;
/// A collapsible group of settings subpages in the sidebar.
pub struct SettingsUmbrella {
pub label: &'static str,
pub subpages: Vec<SettingsSection>,
pub expanded: bool,
/// Saved expanded state from before search began, restored when search is cleared.
pub pre_search_expanded: Option<bool>,
pub button_state_handle: MouseStateHandle,
pub subpage_button_states: Vec<MouseStateHandle>,
}
impl SettingsUmbrella {
pub fn new(label: &'static str, subpages: Vec<SettingsSection>) -> Self {
let subpage_count = subpages.len();
Self {
label,
subpages,
expanded: false,
pre_search_expanded: None,
button_state_handle: MouseStateHandle::default(),
subpage_button_states: (0..subpage_count)
.map(|_| MouseStateHandle::default())
.collect(),
}
}
pub fn toggle(&mut self) {
self.expanded = !self.expanded;
}
/// Returns true if `section` is one of this umbrella's subpages.
pub fn contains(&self, section: SettingsSection) -> bool {
self.subpages.contains(&section)
}
/// Render the umbrella header row (label + chevron).
/// Returns a `Hoverable` so the entire row shares a single hover/click
/// target — i.e. the hover styling and pointing-hand cursor apply to the
/// whole clickable area rather than just the text.
pub fn render_umbrella_row(&self, appearance: &Appearance) -> Hoverable {
let chevron_icon = if self.expanded {
Icon::ChevronUp
} else {
Icon::ChevronDown
};
// Initial chevron color is overridden by the button's font_color when
// rendered, so this just seeds a sensible default.
let text_color = appearance.theme().nonactive_ui_text_color();
// Use a single full-width text button with a text+icon label so the
// text label aligns with other top-level settings items and the
// chevron sits flush-right — while the whole button area receives the
// hover styling and pointing-hand cursor.
appearance
.ui_builder()
.button(ButtonVariant::Text, self.button_state_handle.clone())
.with_text_and_icon_label(TextAndIcon::new(
TextAndIconAlignment::TextFirst,
self.label.to_string(),
chevron_icon.to_warpui_icon(text_color),
MainAxisSize::Max,
MainAxisAlignment::SpaceBetween,
vec2f(16., 16.),
))
.with_style(
UiComponentStyles::default()
.set_border_width(0.)
.set_margin(Coords::default().left(NAV_ITEM_LEFT_MARGIN))
.set_padding(Coords::uniform(8.)),
)
.build()
}
/// Render a single subpage button within this umbrella.
pub fn render_subpage_button(
&self,
index: usize,
appearance: &Appearance,
match_data: MatchData,
is_active: bool,
) -> Option<Hoverable> {
let section = self.subpages.get(index)?;
let mouse_state = self.subpage_button_states.get(index)?.clone();
let label = section.to_string() + &match_data.to_string();
let hoverable = appearance
.ui_builder()
.button(
if is_active {
ButtonVariant::Accent
} else {
ButtonVariant::Text
},
mouse_state,
)
.with_text_label(label)
.with_style(
UiComponentStyles::default()
.set_border_width(0.)
.set_margin(Coords::default().left(SUBPAGE_LEFT_MARGIN))
.set_padding(Coords::uniform(8.))
.set_font_size(SUBPAGE_FONT_SIZE),
)
.build();
Some(hoverable)
}
}
/// A sidebar navigation item: either a direct page link or a collapsible umbrella.
pub enum SettingsNavItem {
/// A top-level page that is rendered directly in the sidebar.
Page(SettingsSection),
/// A collapsible group header whose children are subpage sections.
Umbrella(SettingsUmbrella),
}
+96
View File
@@ -0,0 +1,96 @@
use crate::pane_group::SettingsPane;
use crate::{
pane_group::{PaneContent, PaneId},
PaneViewLocator,
};
use std::collections::HashMap;
use warpui::{Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WindowId};
use super::SettingsView;
struct SettingsPaneData {
locator: Option<PaneViewLocator>,
settings_view: ViewHandle<SettingsView>,
}
/// Singleton model to manage state of settings panes across multiple windows
/// (where only one settings pane can exist per window). Specifically:
/// - Maintains settings view handles to preserve state when panes are hidden
/// - Tracks currently open settings panes and their location
#[derive(Default)]
pub struct SettingsPaneManager {
panes: HashMap<WindowId, SettingsPaneData>,
}
impl SettingsPaneManager {
pub fn new() -> Self {
Self::default()
}
pub fn settings_view(&self, window_id: WindowId) -> ViewHandle<SettingsView> {
self.panes
.get(&window_id)
.expect("Window should have corresponding settings view")
.settings_view
.clone()
}
pub fn register_view(&mut self, window_id: WindowId, view: ViewHandle<SettingsView>) {
if let Some(data) = self.panes.get_mut(&window_id) {
data.settings_view = view;
} else {
self.panes.insert(
window_id,
SettingsPaneData {
locator: None,
settings_view: view,
},
);
}
}
pub fn find_pane(&self, window_id: WindowId) -> Option<PaneViewLocator> {
self.panes.get(&window_id).and_then(|data| data.locator)
}
pub fn register_pane(
&mut self,
pane: &SettingsPane,
pane_group_id: EntityId,
window_id: WindowId,
_ctx: &mut ModelContext<Self>,
) {
if let Some(data) = self.panes.get_mut(&window_id) {
data.locator = Some(PaneViewLocator {
pane_group_id,
pane_id: pane.id(),
});
} else {
log::warn!("Settings view should already exist for settings pane");
}
}
pub fn deregister_pane(
&mut self,
window_id: &WindowId,
pane_group_id: EntityId,
pane_id: PaneId,
_ctx: &mut ModelContext<Self>,
) {
if let Some(data) = self.panes.get_mut(window_id) {
let locator = PaneViewLocator {
pane_group_id,
pane_id,
};
if data.locator == Some(locator) {
data.locator = None;
}
}
}
}
impl Entity for SettingsPaneManager {
type Event = ();
}
/// Mark SettingsPaneManager as global application state.
impl SingletonEntity for SettingsPaneManager {}
@@ -0,0 +1,731 @@
use crate::editor::Event as EditorEvent;
use crate::modal::{Modal, ModalViewState};
use crate::server::server_api::auth::AuthClient;
use crate::util::truncation::truncate_from_end;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
appearance::Appearance,
editor::{EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions},
view_components::{Dropdown as DropdownView, DropdownItem},
};
use chrono::Utc;
use pathfinder_geometry::vector::vec2f;
use warp_core::features::FeatureFlag;
use warpui::elements::{
Border, ChildView, ConstrainedBox, Container, CornerRadius, Empty, Fill, Flex,
MouseStateHandle, ParentElement, Radius, Text,
};
use warpui::elements::{CrossAxisAlignment, Expanded, MainAxisAlignment, MainAxisSize, Padding};
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ui_components::segmented_control::{
LabelConfig, RenderableOptionConfig, SegmentedControl,
};
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
const LABEL_FONT_SIZE: f32 = 14.;
const INPUT_WIDTH: f32 = 428.; // 460px - (2 * 16px) padding
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ApiKeyType {
Personal,
Team,
}
impl ApiKeyType {
fn description(&self) -> &'static str {
match self {
ApiKeyType::Personal => {
"This API key is tied to your user and can make requests against your Warp account."
}
ApiKeyType::Team => {
"This API key is tied to your team and can make requests on behalf of your team."
}
}
}
}
pub struct CreateApiKeyModal {
name_editor: ViewHandle<EditorView>,
expiration_dropdown: ViewHandle<DropdownView<CreateApiKeyModalAction>>,
api_key_type_control: ViewHandle<SegmentedControl<ApiKeyType>>,
expiration: ExpirationOption,
cancel_button_mouse_state: MouseStateHandle,
create_button_mouse_state: MouseStateHandle,
request_state: RequestState,
raw_key_copied: bool,
raw_key: Option<String>,
has_team: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExpirationOption {
OneDay,
ThirtyDays,
NinetyDays,
Never,
}
impl ExpirationOption {
fn display_text(&self) -> &'static str {
match self {
ExpirationOption::OneDay => "1 day",
ExpirationOption::ThirtyDays => "30 days",
ExpirationOption::NinetyDays => "90 days",
ExpirationOption::Never => "Never",
}
}
fn days(&self) -> Option<i64> {
match self {
ExpirationOption::OneDay => Some(1),
ExpirationOption::ThirtyDays => Some(30),
ExpirationOption::NinetyDays => Some(90),
ExpirationOption::Never => None,
}
}
fn all() -> Vec<ExpirationOption> {
vec![
ExpirationOption::NinetyDays,
ExpirationOption::ThirtyDays,
ExpirationOption::OneDay,
ExpirationOption::Never,
]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreateApiKeyModalAction {
Cancel,
Create,
CopyRawKey,
SetExpiration(ExpirationOption),
}
pub enum CreateApiKeyModalEvent {
Close,
Created {
api_key: warp_graphql::queries::api_keys::ApiKeyProperties,
},
Error {
message: String,
},
}
#[derive(PartialEq, Eq)]
enum RequestState {
Idle,
Pending,
Succeeded,
}
impl CreateApiKeyModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let font_family = Appearance::as_ref(ctx).ui_font_family();
let has_team = FeatureFlag::TeamApiKeys.is_enabled()
&& UserWorkspaces::as_ref(ctx).current_team_uid().is_some();
let name_editor = ctx.add_typed_action_view(|ctx| {
let options = SingleLineEditorOptions {
text: TextOptions {
font_family_override: Some(font_family),
..Default::default()
},
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("Warp API Key", ctx);
editor
});
// Expiration dropdown
let expiration_dropdown =
ctx.add_typed_action_view(DropdownView::<CreateApiKeyModalAction>::new);
// API key type segmented control
let api_key_type_control = ctx.add_typed_action_view(move |ctx| {
let options = if has_team {
vec![ApiKeyType::Personal, ApiKeyType::Team]
} else {
vec![ApiKeyType::Personal]
};
SegmentedControl::new(
options,
|key_type, is_selected, app| {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Some(RenderableOptionConfig {
icon_path: "",
icon_color: theme.active_ui_text_color().into(),
label: Some(LabelConfig {
label: match key_type {
ApiKeyType::Personal => "Personal".into(),
ApiKeyType::Team => "Team".into(),
},
width_override: Some(55.0),
color: if is_selected {
theme.active_ui_text_color().into()
} else {
theme.nonactive_ui_text_color().into()
},
}),
tooltip: None,
background: if is_selected {
Fill::Solid(theme.surface_3().into())
} else {
Fill::None
},
})
},
ApiKeyType::Personal,
api_key_type_control_styles(ctx),
)
});
ctx.subscribe_to_view(&api_key_type_control, |me, _, _, ctx| {
ctx.notify();
me.name_editor.update(ctx, |_, ctx| ctx.notify());
});
// Subscribe to UserWorkspaces to update has_team when team membership changes
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, _, ctx| {
me.update_has_team(ctx);
});
// Subscribe to editor events for navigation and validation
ctx.subscribe_to_view(&name_editor, |me, _, event, ctx| {
me.handle_name_editor_event(event, ctx);
});
// Populate expiration dropdown items and default selection (90 days)
let default_expiration = ExpirationOption::NinetyDays;
let items: Vec<DropdownItem<CreateApiKeyModalAction>> = ExpirationOption::all()
.into_iter()
.map(|opt| {
DropdownItem::new(
opt.display_text(),
CreateApiKeyModalAction::SetExpiration(opt),
)
})
.collect();
expiration_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(items, ctx);
// Match the input width (460 - 2*16 padding = 428)
dropdown.set_top_bar_max_width(INPUT_WIDTH);
dropdown.set_menu_width(INPUT_WIDTH, ctx);
dropdown.set_selected_by_action(
CreateApiKeyModalAction::SetExpiration(default_expiration),
ctx,
);
});
Self {
name_editor,
expiration_dropdown,
api_key_type_control,
expiration: default_expiration,
cancel_button_mouse_state: Default::default(),
create_button_mouse_state: Default::default(),
request_state: RequestState::Idle,
raw_key_copied: false,
raw_key: None,
has_team,
}
}
fn create(&mut self, ctx: &mut ViewContext<Self>) {
if self.request_state == RequestState::Pending {
return;
}
let name = self.name_editor.as_ref(ctx).buffer_text(ctx);
// Always allow creation, even with empty name (we'll use a default)
let final_name = if name.trim().is_empty() {
"Warp API Key".to_string()
} else {
name.trim().to_string()
};
self.request_state = RequestState::Pending;
ctx.notify();
// Compute expiration timestamp based on selected option
let expires_at = match self.expiration.days() {
Some(days) => {
let t = Utc::now() + chrono::Duration::days(days);
Some(warp_graphql::scalars::Time::from(t))
}
None => None,
};
// Get team_id if creating for team
let for_team = self.api_key_type_control.as_ref(ctx).selected_option() == ApiKeyType::Team;
let team_id = if for_team {
let workspaces = UserWorkspaces::as_ref(ctx);
match workspaces.current_team_uid() {
Some(uid) => Some(cynic::Id::new(uid.uid())),
None => {
// Fail fast if the user requested a team key but there is no current team.
// This can happen if the team state changed between render and click.
self.request_state = RequestState::Idle;
ctx.emit(CreateApiKeyModalEvent::Error {
message:
"Unable to create a team API key because there is no current team."
.to_string(),
});
ctx.notify();
return;
}
}
} else {
None
};
// Fire mutation via ServerApi AuthClient
let server_api = crate::server::server_api::ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move { server_api.create_api_key(final_name, team_id, expires_at).await },
|me, res, ctx| {
match res {
Ok(warp_graphql::mutations::generate_api_key::GenerateApiKeyResult::GenerateApiKeyOutput(output)) => {
// Notify parent to append
ctx.emit(CreateApiKeyModalEvent::Created { api_key: output.api_key });
// Switch to success view and show raw key
me.request_state = RequestState::Succeeded;
me.raw_key_copied = false;
me.raw_key = Some(output.raw_api_key);
ctx.notify();
}
Ok(warp_graphql::mutations::generate_api_key::GenerateApiKeyResult::UserFacingError(e)) => {
let msg = warp_graphql::client::get_user_facing_error_message(e);
me.request_state = RequestState::Idle;
ctx.emit(CreateApiKeyModalEvent::Error { message: msg });
ctx.notify();
}
Ok(warp_graphql::mutations::generate_api_key::GenerateApiKeyResult::Unknown) | Err(_) => {
me.request_state = RequestState::Idle;
ctx.emit(CreateApiKeyModalEvent::Error { message: "Failed to create API key. Please try again.".to_string() });
ctx.notify();
}
}
},
);
}
fn cancel(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(CreateApiKeyModalEvent::Close);
}
pub fn on_close(&mut self, ctx: &mut ViewContext<Self>) {
self.request_state = RequestState::Idle;
self.raw_key_copied = false;
self.raw_key = None;
self.name_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
}
pub fn on_open(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.name_editor);
}
fn update_has_team(&mut self, ctx: &mut ViewContext<Self>) {
let new_has_team = FeatureFlag::TeamApiKeys.is_enabled()
&& UserWorkspaces::as_ref(ctx).current_team_uid().is_some();
if new_has_team != self.has_team {
self.has_team = new_has_team;
// Update the segmented control options
let options = if new_has_team {
vec![ApiKeyType::Personal, ApiKeyType::Team]
} else {
vec![ApiKeyType::Personal]
};
self.api_key_type_control
.update(ctx, |control, ctx| control.update_options(options, ctx));
ctx.notify();
}
}
fn handle_name_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Enter => {
self.create(ctx);
}
EditorEvent::Escape => {
self.cancel(ctx);
}
EditorEvent::Edited(_) => {
// Re-render when name field changes
ctx.notify();
}
_ => {}
}
}
fn render_success_content(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let button_style = UiComponentStyles {
font_size: Some(14.),
padding: Some(Coords::uniform(8.).left(12.).right(12.)),
..Default::default()
};
let info = Text::new(
"This secret key is shown only once. Copy and store it securely.",
appearance.ui_font_family(),
LABEL_FONT_SIZE,
)
.with_color(theme.nonactive_ui_text_color().into())
.finish();
// Truncated display of the raw key (copy action uses full value)
let raw_full = self.raw_key.as_deref().unwrap_or("");
let display = truncate_from_end(raw_full, 37);
let raw_key_view = Container::new(
Text::new_inline(display, appearance.monospace_font_family(), 12.)
.with_color(theme.active_ui_text_color().into())
.finish(),
)
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_padding(Padding::uniform(8.))
.with_background(theme.surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish();
let copy_label = if self.raw_key_copied {
"Copied"
} else {
"Copy"
};
let copy_icon = if self.raw_key_copied {
warp_core::ui::icons::Icon::Check.to_warpui_icon(appearance.theme().background())
} else {
warp_core::ui::icons::Icon::Copy
.to_warpui_icon(appearance.theme().active_ui_text_color())
};
let mut copy_button_builder = appearance
.ui_builder()
.button(
if self.raw_key_copied {
ButtonVariant::Basic
} else {
ButtonVariant::Outlined
},
self.create_button_mouse_state.clone(),
)
.with_text_and_icon_label(
warpui::ui_components::button::TextAndIcon::new(
warpui::ui_components::button::TextAndIconAlignment::IconFirst,
copy_label,
copy_icon,
MainAxisSize::Min,
MainAxisAlignment::Center,
vec2f(14., 14.),
)
.with_inner_padding(4.),
);
if self.raw_key_copied {
copy_button_builder = copy_button_builder.with_style(UiComponentStyles {
background: Some(appearance.theme().ansi_fg_green().into()),
font_color: Some(appearance.theme().background().into()),
..button_style
});
} else {
copy_button_builder = copy_button_builder.with_style(button_style);
}
let copy_button = copy_button_builder
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(CreateApiKeyModalAction::CopyRawKey))
.finish();
let done_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.cancel_button_mouse_state.clone(),
)
.with_text_label("Done".to_string())
.with_style(button_style)
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(CreateApiKeyModalAction::Cancel))
.finish();
Flex::column()
.with_child(Container::new(info).with_margin_bottom(12.).finish())
.with_child(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., raw_key_view).finish())
.with_child(Container::new(copy_button).with_margin_left(8.).finish())
.finish(),
)
.with_child(
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., Empty::new().finish()).finish())
.with_child(done_button)
.finish(),
)
.with_margin_top(12.)
.finish(),
)
.finish()
}
}
impl Entity for CreateApiKeyModal {
type Event = CreateApiKeyModalEvent;
}
impl View for CreateApiKeyModal {
fn ui_name() -> &'static str {
"CreateApiKeyModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let button_style = UiComponentStyles {
font_size: Some(14.),
padding: Some(Coords::uniform(8.).left(12.).right(12.)),
..Default::default()
};
match self.request_state {
RequestState::Succeeded => self.render_success_content(app),
_ => {
// Entry form (Idle, Pending, Failed)
let selected_key_type = self.api_key_type_control.as_ref(app).selected_option();
let description_text = Text::new(
selected_key_type.description(),
appearance.ui_font_family(),
LABEL_FONT_SIZE,
)
.with_color(theme.nonactive_ui_text_color().into())
.finish();
let name_label = Text::new("Name", appearance.ui_font_family(), LABEL_FONT_SIZE)
.with_color(theme.active_ui_text_color().into())
.finish();
let is_pending = self.request_state == RequestState::Pending;
let mut cancel_button_hover = appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.cancel_button_mouse_state.clone(),
)
.with_text_label("Cancel".to_string())
.with_style(button_style)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(CreateApiKeyModalAction::Cancel);
});
if is_pending {
cancel_button_hover = cancel_button_hover.disable();
}
let cancel_button = cancel_button_hover.finish();
let mut create_button_hover = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.create_button_mouse_state.clone(),
)
.with_text_label(if is_pending {
"Creating…".to_string()
} else {
"Create key".to_string()
})
.with_style(button_style)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(CreateApiKeyModalAction::Create);
});
if is_pending {
create_button_hover = create_button_hover.disable();
}
let create_button = create_button_hover.finish();
let buttons_row = Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., Empty::new().finish()).finish())
.with_child(cancel_button)
.with_child(Container::new(create_button).with_margin_left(12.).finish())
.finish(),
)
.with_margin_top(12.)
.finish();
let mut col = Flex::column();
// Show segmented control only if user has a team
if self.has_team {
let type_label =
Text::new("Type", appearance.ui_font_family(), LABEL_FONT_SIZE)
.with_color(theme.active_ui_text_color().into())
.finish();
col.add_child(Container::new(type_label).with_margin_bottom(4.).finish());
col.add_child(
Container::new(ChildView::new(&self.api_key_type_control).finish())
.with_margin_bottom(16.)
.finish(),
);
}
col.add_child(
Container::new(description_text)
.with_margin_bottom(24.)
.finish(),
);
col.add_child(Container::new(name_label).with_margin_bottom(4.).finish());
col.add_child(
ConstrainedBox::new(
Container::new(ChildView::new(&self.name_editor).finish())
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_padding(Padding::uniform(4.))
.with_background(theme.surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish(),
)
.with_width(INPUT_WIDTH)
.finish(),
);
let expiration_label =
Text::new("Expiration", appearance.ui_font_family(), LABEL_FONT_SIZE)
.with_color(theme.active_ui_text_color().into())
.finish();
col.add_child(
Container::new(expiration_label)
.with_margin_bottom(4.)
.with_margin_top(16.)
.finish(),
);
col.add_child(
ConstrainedBox::new(
Container::new(ChildView::new(&self.expiration_dropdown).finish())
.with_margin_bottom(24.)
.finish(),
)
.with_width(INPUT_WIDTH)
.finish(),
);
col.add_child(buttons_row);
col.finish()
}
}
}
}
impl TypedActionView for CreateApiKeyModal {
type Action = CreateApiKeyModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
CreateApiKeyModalAction::Cancel => self.cancel(ctx),
CreateApiKeyModalAction::Create => self.create(ctx),
CreateApiKeyModalAction::CopyRawKey => {
let content = self.raw_key.clone().unwrap_or_default();
ctx.clipboard()
.write(warpui::clipboard::ClipboardContent::plain_text(content));
self.raw_key_copied = true;
// Success toast
let window_id = ctx.window_id();
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::success(
"Secret key copied.".to_string(),
);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
ctx.notify();
}
CreateApiKeyModalAction::SetExpiration(exp) => {
// The dropdown component already updates its own selection in response to the
// menu click; attempting to re-set the selection here causes a circular update.
self.expiration = *exp;
ctx.notify();
}
}
}
}
pub struct CreateApiKeyModalViewState {
state: ModalViewState<Modal<CreateApiKeyModal>>,
}
impl CreateApiKeyModalViewState {
pub fn new(state: ModalViewState<Modal<CreateApiKeyModal>>) -> Self {
Self { state }
}
pub fn is_open(&self) -> bool {
self.state.is_open()
}
pub fn render(&self) -> Box<dyn Element> {
self.state.render()
}
pub fn open<T: View>(&mut self, ctx: &mut ViewContext<T>) {
self.state.open();
self.state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_open(ctx);
});
});
}
pub fn set_title<T: View>(&mut self, title: Option<String>, ctx: &mut ViewContext<T>) {
self.state.view.update(ctx, |modal, ctx| {
modal.set_title(title);
ctx.notify();
});
ctx.notify();
}
pub fn close<T: View>(&mut self, ctx: &mut ViewContext<T>) {
self.state.close();
self.state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_close(ctx);
});
});
}
}
fn api_key_type_control_styles(app: &AppContext) -> UiComponentStyles {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.ui_font_size()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.0))),
border_width: Some(1.0),
border_color: Some(Fill::Solid(theme.outline().into())),
background: Some(Fill::Solid(theme.surface_2().into())),
height: Some(24.0),
padding: Some(Coords::uniform(2.0)),
..Default::default()
}
}
@@ -0,0 +1,128 @@
use crate::server::{ids::ApiKeyUid, server_api::auth::AuthClient};
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::MouseStateHandle, ui_components::components::UiComponent, AppContext, Element,
Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::ui_components::{buttons::icon_button, icons::Icon};
#[derive(PartialEq, Eq)]
enum RequestState {
Idle,
Pending,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ExpireApiKeyButtonAction {
ExpireApiKey,
}
pub enum ExpireApiKeyButtonEvent {
ExpireApiKeySucceeded { uid: ApiKeyUid },
ExpireApiKeyFailed { message: String },
}
pub struct ExpireApiKeyButton {
key_uid: ApiKeyUid,
button_mouse_state: MouseStateHandle,
request_state: RequestState,
}
impl ExpireApiKeyButton {
pub fn new(key_uid: ApiKeyUid) -> Self {
Self {
key_uid,
button_mouse_state: Default::default(),
request_state: RequestState::Idle,
}
}
fn expire_api_key(&mut self, ctx: &mut ViewContext<Self>) {
if self.request_state == RequestState::Pending {
return;
}
self.request_state = RequestState::Pending;
ctx.notify();
let server_api = crate::server::server_api::ServerApiProvider::as_ref(ctx).get();
let uid_for_req = self.key_uid.clone();
ctx.spawn(
async move { server_api.expire_api_key(&uid_for_req).await },
move |me, res, ctx| match res {
Ok(
warp_graphql::mutations::expire_api_key::ExpireApiKeyResult::ExpireApiKeyOutput(
_output,
),
) => {
me.request_state = RequestState::Idle;
ctx.emit(ExpireApiKeyButtonEvent::ExpireApiKeySucceeded {
uid: me.key_uid.clone(),
});
ctx.notify();
}
Ok(
warp_graphql::mutations::expire_api_key::ExpireApiKeyResult::UserFacingError(e),
) => {
let _msg = warp_graphql::client::get_user_facing_error_message(e);
me.request_state = RequestState::Idle;
ctx.emit(ExpireApiKeyButtonEvent::ExpireApiKeyFailed { message: _msg });
ctx.notify();
}
Ok(warp_graphql::mutations::expire_api_key::ExpireApiKeyResult::Unknown)
| Err(_) => {
me.request_state = RequestState::Idle;
ctx.emit(ExpireApiKeyButtonEvent::ExpireApiKeyFailed {
message: "Failed to delete API key. Please try again.".to_string(),
});
ctx.notify();
}
},
);
}
}
impl View for ExpireApiKeyButton {
fn ui_name() -> &'static str {
"ExpireApiKeyButton"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let expire_icon = match self.request_state {
RequestState::Pending => Icon::Loading,
RequestState::Idle => Icon::Trash,
};
let mut expire_button = icon_button(
appearance,
expire_icon,
false,
self.button_mouse_state.clone(),
)
.build();
if self.request_state != RequestState::Idle {
expire_button = expire_button.disable();
}
expire_button
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ExpireApiKeyButtonAction::ExpireApiKey);
})
.finish()
}
}
impl Entity for ExpireApiKeyButton {
type Event = ExpireApiKeyButtonEvent;
}
impl TypedActionView for ExpireApiKeyButton {
type Action = ExpireApiKeyButtonAction;
fn handle_action(&mut self, action: &ExpireApiKeyButtonAction, ctx: &mut ViewContext<Self>) {
match action {
ExpireApiKeyButtonAction::ExpireApiKey => {
self.expire_api_key(ctx);
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
mod create_api_key_modal;
mod expire_api_key_button;
pub use create_api_key_modal::{
CreateApiKeyModal, CreateApiKeyModalEvent, CreateApiKeyModalViewState,
};
pub use expire_api_key_button::{ExpireApiKeyButton, ExpireApiKeyButtonEvent};
+726
View File
@@ -0,0 +1,726 @@
use super::{
platform::{
CreateApiKeyModal, CreateApiKeyModalEvent, CreateApiKeyModalViewState, ExpireApiKeyButton,
ExpireApiKeyButtonEvent,
},
settings_page::{
MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget,
CONTENT_FONT_SIZE, SUBHEADER_FONT_SIZE,
},
SettingsSection,
};
use crate::auth::AuthStateProvider;
use crate::server::{ids::ApiKeyUid, server_api::auth::AuthClient};
use crate::util::truncation::truncate_from_end;
use crate::{
appearance::Appearance,
modal::{Modal, ModalEvent, ModalViewState},
ui_components::icons::Icon,
util::time_format::format_approx_duration_from_now_utc,
};
use chrono::{DateTime, Utc};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use std::collections::HashMap;
use warp_core::features::FeatureFlag;
use warpui::{
elements::{
Align, Border, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Element, Empty,
Expanded, Flex, FormattedTextElement, HighlightedHyperlink, MainAxisSize, MouseStateHandle,
Padding, ParentElement, Shrinkable, Text,
},
fonts::{Properties, Weight},
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
const MODAL_WIDTH: f32 = 460.;
const MODAL_HEIGHT: f32 = 320.;
const API_KEY_DOCS_URL: &str = "https://docs.warp.dev/reference/cli/api-keys";
#[derive(Clone, Copy)]
pub enum PlatformPageViewEvent {
ShowCreateApiKeyModal,
HideCreateApiKeyModal,
}
#[derive(Clone, Debug, PartialEq)]
pub enum PlatformPageAction {
ShowCreateApiKeyModal,
HyperlinkClick(String),
}
pub struct PlatformPageView {
page: PageType<Self>,
create_api_key_modal_state: CreateApiKeyModalViewState,
api_keys: Vec<APIKeyProperties>,
expire_buttons: HashMap<ApiKeyUid, ViewHandle<ExpireApiKeyButton>>,
is_loading: bool,
documentation_link_highlight: HighlightedHyperlink,
}
impl PlatformPageView {
fn fetch_api_keys(&mut self, ctx: &mut ViewContext<PlatformPageView>) {
// Set loading state only if we don't have any keys yet
if self.api_keys.is_empty() {
self.is_loading = true;
ctx.notify();
}
// Build and send the GraphQL query
let server_api = crate::server::server_api::ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move { server_api.list_api_keys().await },
|me, res, ctx| {
me.is_loading = false;
match res {
Ok(keys) => {
me.api_keys = keys
.into_iter()
.map(|gql_key| {
// Ensure the per-key expire button exists
let uid = gql_key.uid.into_inner();
me.ensure_expire_button_for_key(ctx, uid.clone());
let scope = match gql_key.owner_type {
warp_graphql::object_permissions::OwnerType::User => {
ApiKeyScope::Personal
}
warp_graphql::object_permissions::OwnerType::Team => {
ApiKeyScope::Team
}
};
APIKeyProperties::new(
uid,
gql_key.name,
gql_key.key_suffix,
scope,
gql_key.created_at.utc(),
gql_key.last_used_at.map(|t| t.utc()),
gql_key.expires_at.map(|t| t.utc()),
)
})
.collect();
ctx.notify();
}
Err(err) => {
let window_id = ctx.window_id();
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast =
crate::view_components::DismissibleToast::error(format!("{err}"));
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
ctx.notify();
}
}
},
);
}
pub fn new(ctx: &mut ViewContext<PlatformPageView>) -> Self {
// Create the modal body
let create_api_key_body = ctx.add_typed_action_view(CreateApiKeyModal::new);
ctx.subscribe_to_view(&create_api_key_body, |me, _, event, ctx| {
me.handle_create_api_key_modal_event(event, ctx);
});
// Create the modal wrapper
let create_api_key_modal_view = ctx.add_typed_action_view(|ctx| {
Modal::new(Some("New API key".to_string()), create_api_key_body, ctx)
.with_modal_style(UiComponentStyles {
width: Some(MODAL_WIDTH),
height: Some(MODAL_HEIGHT),
..Default::default()
})
.with_header_style(UiComponentStyles {
padding: Some(Coords {
top: 24.,
bottom: 0.,
left: 24.,
right: 24.,
}),
font_size: Some(16.),
font_weight: Some(warpui::fonts::Weight::Bold),
..Default::default()
})
.with_body_style(UiComponentStyles {
padding: Some(Coords {
top: 0.,
bottom: 24.,
left: 24.,
right: 24.,
}),
..Default::default()
})
.with_background_opacity(100)
.with_dismiss_on_click()
});
ctx.subscribe_to_view(&create_api_key_modal_view, |me, _, event, ctx| {
me.handle_modal_event(event, ctx);
});
PlatformPageView {
page: PageType::new_monolith(PlatformPageWidget::default(), None, true),
create_api_key_modal_state: CreateApiKeyModalViewState::new(ModalViewState::new(
create_api_key_modal_view,
)),
api_keys: vec![],
expire_buttons: HashMap::new(),
is_loading: true,
documentation_link_highlight: HighlightedHyperlink::default(),
}
}
fn show_create_api_key_modal(&mut self, ctx: &mut ViewContext<Self>) {
// Ensure header reads "New API key" when opening the form
self.create_api_key_modal_state
.set_title(Some("New API key".to_string()), ctx);
self.create_api_key_modal_state.open(ctx);
ctx.emit(PlatformPageViewEvent::ShowCreateApiKeyModal);
}
fn hide_create_api_key_modal(&mut self, ctx: &mut ViewContext<Self>) {
self.create_api_key_modal_state.close(ctx);
ctx.emit(PlatformPageViewEvent::HideCreateApiKeyModal);
}
fn handle_modal_event(&mut self, event: &ModalEvent, ctx: &mut ViewContext<Self>) {
match event {
ModalEvent::Close => {
self.hide_create_api_key_modal(ctx);
}
}
}
fn handle_create_api_key_modal_event(
&mut self,
event: &CreateApiKeyModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
CreateApiKeyModalEvent::Close => {
self.hide_create_api_key_modal(ctx);
}
CreateApiKeyModalEvent::Created { api_key } => {
// Switch modal header off for success screen
self.create_api_key_modal_state
.set_title(Some("Save your key".to_string()), ctx);
// Append to list locally
// Ensure the per-key expire button exists
let uid = api_key.uid.clone().into_inner();
self.ensure_expire_button_for_key(ctx, uid.clone());
let scope = match api_key.owner_type {
warp_graphql::object_permissions::OwnerType::User => ApiKeyScope::Personal,
warp_graphql::object_permissions::OwnerType::Team => ApiKeyScope::Team,
};
let ui_key = APIKeyProperties::new(
uid,
api_key.name.clone(),
api_key.key_suffix.clone(),
scope,
api_key.created_at.utc(),
api_key.last_used_at.map(|t| t.utc()),
api_key.expires_at.map(|t| t.utc()),
);
self.api_keys.push(ui_key);
ctx.notify();
}
CreateApiKeyModalEvent::Error { message } => {
// Show an error toast with the provided message
let window_id = ctx.window_id();
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::error(message.clone());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
ctx.notify();
}
}
}
pub fn get_modal_content(&self) -> Option<Box<dyn Element>> {
if self.create_api_key_modal_state.is_open() {
Some(self.create_api_key_modal_state.render())
} else {
None
}
}
fn ensure_expire_button_for_key(&mut self, ctx: &mut ViewContext<Self>, uid: ApiKeyUid) {
if self.expire_buttons.contains_key(&uid) {
return;
}
let handle = ctx.add_typed_action_view(|_ctx| ExpireApiKeyButton::new(uid.clone()));
ctx.subscribe_to_view(&handle, |me, _emitter, event, ctx| match event {
ExpireApiKeyButtonEvent::ExpireApiKeySucceeded { uid } => {
me.api_keys.retain(|k| k.uid != *uid);
me.expire_buttons.remove(uid);
let window_id = ctx.window_id();
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::success(
"API key deleted".to_string(),
);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
ctx.notify();
}
ExpireApiKeyButtonEvent::ExpireApiKeyFailed { message } => {
let window_id = ctx.window_id();
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::error(message.clone());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
ctx.notify();
}
});
self.expire_buttons.insert(uid, handle);
}
}
impl Entity for PlatformPageView {
type Event = PlatformPageViewEvent;
}
impl TypedActionView for PlatformPageView {
type Action = PlatformPageAction;
fn handle_action(&mut self, action: &PlatformPageAction, ctx: &mut ViewContext<Self>) {
match action {
PlatformPageAction::ShowCreateApiKeyModal => {
self.show_create_api_key_modal(ctx);
}
PlatformPageAction::HyperlinkClick(url) => {
ctx.open_url(url);
}
}
}
}
impl View for PlatformPageView {
fn ui_name() -> &'static str {
"PlatformPage"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
}
}
#[derive(Debug, Clone)]
struct APIKeyProperties {
uid: ApiKeyUid,
name: String,
key_suffix: String,
scope: ApiKeyScope,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy)]
enum ApiKeyScope {
Personal,
Team,
}
impl APIKeyProperties {
fn new(
uid: ApiKeyUid,
name: impl Into<String>,
key_suffix: impl Into<String>,
scope: ApiKeyScope,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
) -> Self {
Self {
uid,
name: name.into(),
key_suffix: key_suffix.into(),
scope,
created_at,
last_used_at,
expires_at,
}
}
}
#[derive(Default)]
struct PlatformPageWidget {
create_api_key_button_mouse_state: MouseStateHandle,
}
impl SettingsWidget for PlatformPageWidget {
type View = PlatformPageView;
fn search_terms(&self) -> &str {
"oz cloud platform api keys authentication"
}
fn render(
&self,
view: &PlatformPageView,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
// Main container
Flex::column()
.with_child(self.render_api_keys_section(appearance, view, app))
.finish()
}
}
impl PlatformPageWidget {
fn render_description_with_link(
&self,
view: &PlatformPageView,
appearance: &Appearance,
) -> Box<dyn Element> {
let text = vec![
FormattedTextFragment::plain_text("Create and manage API keys to allow other Oz cloud agents to access your Warp account.\nFor more information, visit the "),
FormattedTextFragment::hyperlink("Documentation.", API_KEY_DOCS_URL),
];
let text_element = FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(text)]),
CONTENT_FONT_SIZE,
appearance.ui_font_family(),
appearance.ui_font_family(),
appearance.theme().nonactive_ui_text_color().into(),
view.documentation_link_highlight.clone(),
)
.with_hyperlink_font_color(appearance.theme().accent().into_solid());
let text_element = text_element.register_default_click_handlers(|url, ctx, _| {
ctx.dispatch_typed_action(PlatformPageAction::HyperlinkClick(url.url.clone()));
});
text_element.finish()
}
fn render_api_keys_section(
&self,
appearance: &Appearance,
view: &PlatformPageView,
_app: &AppContext,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
let api_keys = &view.api_keys;
let mut col = Flex::column();
col.add_child(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new_inline("Oz Cloud API Keys", appearance.ui_font_family(), 16.)
.with_style(Properties::default().weight(Weight::Bold))
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.with_child(Shrinkable::new(1.0, Empty::new().finish()).finish())
.with_child(
ui_builder
.button(
ButtonVariant::Outlined,
self.create_api_key_button_mouse_state.clone(),
)
.with_text_label("+ Create API Key".to_string())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(PlatformPageAction::ShowCreateApiKeyModal);
})
.finish(),
)
.finish(),
);
col.add_child(
Container::new(self.render_description_with_link(view, appearance))
.with_margin_top(8.)
.finish(),
);
if api_keys.is_empty() {
if view.is_loading {
// Render nothing (just the description) while loading
} else {
col.add_child(self.render_zero_state(appearance));
}
} else {
col.add_child(self.render_api_keys_header(appearance));
col.add_child(self.render_api_keys_rows(appearance, view, api_keys));
}
col.finish()
}
fn render_api_keys_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut header_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
header_row
.add_child(Expanded::new(1., self.render_header_cell(appearance, "Name")).finish());
header_row
.add_child(Expanded::new(1., self.render_header_cell(appearance, "Key")).finish());
if FeatureFlag::TeamApiKeys.is_enabled() {
header_row.add_child(
Expanded::new(1., self.render_header_cell(appearance, "Scope")).finish(),
);
}
header_row
.add_child(Expanded::new(1., self.render_header_cell(appearance, "Created")).finish());
header_row.add_child(
Expanded::new(1., self.render_header_cell(appearance, "Last used")).finish(),
);
header_row.add_child(
Expanded::new(1., self.render_header_cell(appearance, "Expires at")).finish(),
);
header_row.add_child(Expanded::new(0.5, self.render_header_cell(appearance, "")).finish());
Container::new(header_row.finish())
.with_margin_top(16.)
.with_padding_bottom(8.)
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
.finish()
}
fn render_api_keys_rows(
&self,
appearance: &Appearance,
view: &PlatformPageView,
api_keys: &[APIKeyProperties],
) -> Box<dyn Element> {
let mut col = Flex::column();
for key in api_keys.iter() {
col.add_child(self.render_api_key_row(appearance, view, key));
}
col.finish()
}
fn render_header_cell(&self, appearance: &Appearance, label: &str) -> Box<dyn Element> {
Container::new(
Text::new_inline(
label.to_string(),
appearance.ui_font_family(),
CONTENT_FONT_SIZE,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish(),
)
.with_padding(Padding::uniform(8.))
.finish()
}
fn render_api_key_row(
&self,
appearance: &Appearance,
view: &PlatformPageView,
key: &APIKeyProperties,
) -> Box<dyn Element> {
let created = format_approx_duration_from_now_utc(key.created_at);
let last_used = key
.last_used_at
.map(format_approx_duration_from_now_utc)
.unwrap_or_else(|| "Never".to_owned());
let expires_at = key
.expires_at
.map(|dt| format!("{}", dt.format("%b %-d, %Y")))
.unwrap_or_else(|| "Never".to_owned());
// Truncate long names to keep columns aligned
let name_display = truncate_from_end(&key.name, 21);
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
// TODO: use appearance.ui_font_size() instead of hardcoded 12
row.add_child(
Expanded::new(
1.,
Container::new(
Text::new_inline(name_display, appearance.ui_font_family(), 13.)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.with_padding(Padding::uniform(8.))
.finish(),
)
.finish(),
);
row.add_child(
Expanded::new(
1.,
Container::new(
Text::new_inline(
format!("wk-**{}", key.key_suffix),
appearance.monospace_font_family(),
12.,
)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.with_padding(Padding::uniform(8.))
.finish(),
)
.finish(),
);
if FeatureFlag::TeamApiKeys.is_enabled() {
let scope_display = match key.scope {
ApiKeyScope::Personal => "Personal",
ApiKeyScope::Team => "Team",
};
row.add_child(
Expanded::new(
1.,
Container::new(
Text::new_inline(scope_display, appearance.ui_font_family(), 12.)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish(),
)
.with_padding(Padding::uniform(8.))
.finish(),
)
.finish(),
);
}
row.add_child(
Expanded::new(
1.,
Container::new(
Text::new_inline(created, appearance.ui_font_family(), 12.)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish(),
)
.with_padding(Padding::uniform(8.))
.finish(),
)
.finish(),
);
row.add_child(
Expanded::new(
1.,
Container::new(
Text::new_inline(last_used, appearance.ui_font_family(), 12.)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish(),
)
.with_padding(Padding::uniform(8.))
.finish(),
)
.finish(),
);
row.add_child(
Expanded::new(
1.,
Container::new(
Text::new_inline(expires_at, appearance.ui_font_family(), 12.)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish(),
)
.with_padding(Padding::uniform(8.))
.finish(),
)
.finish(),
);
// Expire button column
let expire_button = view
.expire_buttons
.get(&key.uid)
.map(|handle| ChildView::new(handle).finish())
// Fallback in case the button is not yet created
.unwrap_or_else(|| Empty::new().finish());
row.add_child(Expanded::new(0.5, expire_button).finish());
Container::new(row.finish())
.with_vertical_padding(12.)
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
.finish()
}
fn render_zero_state(&self, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
Align::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ConstrainedBox::new(
Icon::Key
.to_warpui_icon(appearance.theme().nonactive_ui_text_color())
.finish(),
)
.with_width(48.)
.with_height(48.)
.finish(),
)
.with_child(
Container::new(
Text::new(
"No API Keys",
appearance.ui_font_family(),
SUBHEADER_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
)
.with_margin_top(16.)
.finish(),
)
.with_child(
Container::new(
Text::new(
"Create a key to manage external access to Warp",
appearance.ui_font_family(),
CONTENT_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.with_margin_top(8.)
.finish(),
)
.finish(),
)
.finish(),
)
.with_margin_top(80.)
.finish()
}
}
impl SettingsPageMeta for PlatformPageView {
fn section() -> SettingsSection {
SettingsSection::OzCloudAPIKeys
}
fn should_render(&self, ctx: &AppContext) -> bool {
let is_anonymous = AuthStateProvider::as_ref(ctx)
.get()
.is_anonymous_or_logged_out();
!is_anonymous && FeatureFlag::APIKeyManagement.is_enabled()
}
fn on_page_selected(&mut self, _allow_steal_focus: bool, ctx: &mut ViewContext<Self>) {
// Always fetch/refresh API keys when page is selected to keep data fresh
self.fetch_api_keys(ctx);
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
self.page.update_filter(query, ctx)
}
fn scroll_to_widget(&mut self, widget_id: &'static str) {
self.page.scroll_to_widget(widget_id)
}
fn clear_highlighted_widget(&mut self) {
self.page.clear_highlighted_widget();
}
}
impl From<ViewHandle<PlatformPageView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<PlatformPageView>) -> Self {
SettingsPageViewHandle::OzCloudAPIKeys(view_handle)
}
}
@@ -0,0 +1,346 @@
use crate::editor::Event as EditorEvent;
use crate::modal::{Modal, ModalViewState};
use crate::{
appearance::Appearance,
editor::{EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions},
};
use regex::Regex;
use warp_editor::editor::NavigationKey;
use warpui::elements::{CrossAxisAlignment, Expanded, MainAxisSize};
use warpui::{
elements::{ChildView, Container, Empty, Flex, MouseStateHandle, ParentElement, Text},
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
const LABEL_FONT_SIZE: f32 = 12.;
pub struct AddRegexModal {
name_editor: ViewHandle<EditorView>,
pattern_editor: ViewHandle<EditorView>,
cancel_button_mouse_state: MouseStateHandle,
submit_button_mouse_state: MouseStateHandle,
}
#[derive(Debug)]
pub enum AddRegexModalAction {
Cancel,
Submit,
}
pub enum AddRegexModalEvent {
Close,
Submit { name: String, pattern: String },
}
impl AddRegexModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let font_family = Appearance::as_ref(ctx).ui_font_family();
let name_editor = ctx.add_typed_action_view(|ctx| {
let options = SingleLineEditorOptions {
text: TextOptions {
font_family_override: Some(font_family),
..Default::default()
},
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("e.g. \"Google API Key\"", ctx);
editor
});
let pattern_editor = ctx.add_typed_action_view(|ctx| {
let options = SingleLineEditorOptions {
text: TextOptions {
font_family_override: Some(font_family),
..Default::default()
},
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("\\bAIza[0-9A-Za-z-_]{35}\\b", ctx);
editor
});
// Subscribe to editor events for tab navigation and re-rendering
ctx.subscribe_to_view(&name_editor, |me, _, event, ctx| {
me.handle_name_editor_event(event, ctx);
});
ctx.subscribe_to_view(&pattern_editor, |me, _, event, ctx| {
me.handle_pattern_editor_event(event, ctx);
});
Self {
name_editor,
pattern_editor,
cancel_button_mouse_state: Default::default(),
submit_button_mouse_state: Default::default(),
}
}
fn submit(&mut self, ctx: &mut ViewContext<Self>) {
let name = self.name_editor.as_ref(ctx).buffer_text(ctx);
let pattern = self.pattern_editor.as_ref(ctx).buffer_text(ctx);
let is_valid_regex = Regex::new(&pattern).is_ok();
if !pattern.trim().is_empty() && is_valid_regex {
ctx.emit(AddRegexModalEvent::Submit { name, pattern });
}
}
fn cancel(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(AddRegexModalEvent::Close);
}
pub fn on_close(&mut self, ctx: &mut ViewContext<Self>) {
self.name_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
self.pattern_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
}
pub fn on_open(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.name_editor);
}
fn handle_name_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Navigate(NavigationKey::Tab) => {
ctx.focus(&self.pattern_editor);
}
EditorEvent::Navigate(NavigationKey::ShiftTab) => {
// Wrap around to pattern editor (last field)
ctx.focus(&self.pattern_editor);
}
EditorEvent::Enter => {
// Submit if pattern is not empty and valid regex (same logic as submit button)
let pattern = self.pattern_editor.as_ref(ctx).buffer_text(ctx);
let is_valid_regex = Regex::new(&pattern).is_ok();
if !pattern.trim().is_empty() && is_valid_regex {
self.submit(ctx);
}
}
EditorEvent::Escape => {
// Close modal like clicking Cancel or X button
self.cancel(ctx);
}
EditorEvent::Edited(_) => {
// Re-render to update validation when name field changes
ctx.notify();
}
_ => {}
}
}
fn handle_pattern_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Navigate(NavigationKey::Tab) => {
ctx.focus(&self.name_editor);
}
EditorEvent::Navigate(NavigationKey::ShiftTab) => {
ctx.focus(&self.name_editor);
}
EditorEvent::Enter => {
// Submit if pattern is not empty and valid regex (same logic as submit button)
let pattern = self.pattern_editor.as_ref(ctx).buffer_text(ctx);
let is_valid_regex = Regex::new(&pattern).is_ok();
if !pattern.trim().is_empty() && is_valid_regex {
self.submit(ctx);
}
}
EditorEvent::Escape => {
// Close modal like clicking Cancel or X button
self.cancel(ctx);
}
EditorEvent::Edited(_) => {
// Re-render to update button state when pattern field changes
ctx.notify();
}
_ => {}
}
}
}
impl Entity for AddRegexModal {
type Event = AddRegexModalEvent;
}
impl View for AddRegexModal {
fn ui_name() -> &'static str {
"AddRegexModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
// Check if regex field has at least 1 character
let pattern_text = self.pattern_editor.as_ref(app).buffer_text(app);
let is_valid_regex = Regex::new(&pattern_text).is_ok();
let is_submit_enabled = !pattern_text.trim().is_empty() && is_valid_regex;
let name_label = Text::new(
"Name (optional)",
appearance.ui_font_family(),
LABEL_FONT_SIZE,
)
.with_color(theme.active_ui_text_color().into())
.finish();
let regex_label = Text::new(
"Regex pattern",
appearance.ui_font_family(),
LABEL_FONT_SIZE,
)
.with_color(theme.active_ui_text_color().into())
.finish();
let button_style = UiComponentStyles {
font_size: Some(14.),
padding: Some(Coords::uniform(8.).left(12.).right(12.)),
..Default::default()
};
let mut add_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.submit_button_mouse_state.clone(),
)
.with_text_label("Add regex".to_string())
.with_style(button_style);
if !is_submit_enabled {
add_button = add_button.disabled();
}
let buttons_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Expanded::new(
1.,
Container::new(if !is_valid_regex && !pattern_text.trim().is_empty() {
Text::new(
"Invalid regex",
appearance.ui_font_family(),
LABEL_FONT_SIZE,
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into(),
)
.finish()
} else {
Empty::new().finish()
})
.with_margin_bottom(8.)
.finish(),
)
.finish(),
)
.with_child(
appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.cancel_button_mouse_state.clone(),
)
.with_text_label("Cancel".to_string())
.with_style(button_style)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AddRegexModalAction::Cancel);
})
.finish(),
)
.with_child(
Container::new(
add_button
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AddRegexModalAction::Submit);
})
.finish(),
)
.with_margin_left(12.)
.finish(),
)
.finish();
Flex::column()
.with_child(Container::new(name_label).with_margin_bottom(4.).finish())
.with_child(
Container::new(ChildView::new(&self.name_editor).finish())
.with_margin_bottom(16.)
.finish(),
)
.with_child(Container::new(regex_label).with_margin_bottom(4.).finish())
.with_child(
Container::new(ChildView::new(&self.pattern_editor).finish())
.with_margin_bottom(24.)
.finish(),
)
.with_child(buttons_row)
.finish()
}
}
impl TypedActionView for AddRegexModal {
type Action = AddRegexModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AddRegexModalAction::Cancel => self.cancel(ctx),
AddRegexModalAction::Submit => self.submit(ctx),
}
}
}
pub struct AddRegexModalViewState {
state: ModalViewState<Modal<AddRegexModal>>,
}
impl AddRegexModalViewState {
pub fn new(state: ModalViewState<Modal<AddRegexModal>>) -> Self {
Self { state }
}
pub fn is_open(&self) -> bool {
self.state.is_open()
}
pub fn render(&self) -> Box<dyn Element> {
self.state.render()
}
pub fn open<T: View>(&mut self, ctx: &mut ViewContext<T>) {
self.state.open();
self.state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_open(ctx);
});
});
}
pub fn close<T: View>(&mut self, ctx: &mut ViewContext<T>) {
self.state.close();
self.state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_close(ctx);
});
});
}
}
+3
View File
@@ -0,0 +1,3 @@
mod add_regex_modal;
pub use add_regex_modal::{AddRegexModal, AddRegexModalEvent, AddRegexModalViewState};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,375 @@
//! Footer rendered at the bottom of the settings nav rail.
//!
//! The footer is the user's always-visible entrypoint into `settings.toml`.
//! It takes one of three forms:
//! * Hidden when the `SettingsFile` feature flag is disabled.
//! * An inline yellow error alert (mirroring the workspace-level banner in
//! `Workspace::render_settings_error_banner`) when the settings file has an
//! error *and* the user has dismissed the workspace banner.
//! * Otherwise, a plain bordered "Open settings file" button.
use crate::appearance::Appearance;
use crate::settings::SettingsFileError;
use crate::ui_components::icons::Icon;
use crate::WorkspaceAction;
use pathfinder_color::ColorU;
use warp_core::ui::color::coloru_with_opacity;
use warp_core::ui::theme::Fill;
use warpui::elements::{
Border, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Element, Empty, Expanded, Flex, Highlight, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text,
Wrap,
};
use warpui::fonts::{FamilyId, Properties, Weight};
use warpui::platform::Cursor;
/// Horizontal + vertical padding applied to the footer inside the sidebar.
const FOOTER_PADDING: f32 = 12.;
/// Font size used for the button label and the alert copy; matches the
/// Figma spec for both designs.
const FOOTER_FONT_SIZE: f32 = 12.;
/// Height of the plain "Open settings file" button.
const OPEN_BUTTON_HEIGHT: f32 = 32.;
/// Height of action buttons inside the error alert.
const ALERT_ACTION_BUTTON_HEIGHT: f32 = 24.;
/// Size of the leading icons (search-sm, code-02, alert-circle, oz).
const FOOTER_ICON_SIZE: f32 = 16.;
/// Size of the Oz brand mark inside the "Fix with Oz" button. Matches the
/// Figma spec and the workspace banner's secondary-button icon sizing.
const ALERT_OZ_ICON_SIZE: f32 = 14.;
/// Horizontal padding inside the "Open file" / "Fix with Oz" action buttons.
/// Matches the workspace banner's secondary button pad.
const ALERT_BUTTON_HORIZONTAL_PADDING: f32 = 8.;
/// Spacing between the two action buttons when they fit on one row.
const ALERT_BUTTON_SPACING: f32 = 4.;
/// Maximum height of the scrollable text region inside the error alert. If
/// the settings error's description exceeds this, the text scrolls within
/// the alert so the footer doesn't balloon to fill the sidebar. The action
/// buttons below the text always remain visible.
const ALERT_TEXT_MAX_HEIGHT: f32 = 140.;
/// Which variant of the footer should be shown.
///
/// Extracted as a pure enum so the decision logic can be unit-tested without
/// rendering a full `SettingsView`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsFooterKind {
/// Footer is not rendered at all (feature flag off).
Hidden,
/// Render the plain "Open settings file" button.
OpenButton,
/// Render the inline yellow error alert.
ErrorAlert,
}
impl SettingsFooterKind {
/// Decide which footer variant to render based on the three inputs that
/// gate the footer: the `SettingsFile` feature flag, the current settings
/// file error (if any), and whether the user has dismissed the workspace
/// banner for invalid settings.
pub fn choose(feature_enabled: bool, has_error: bool, banner_dismissed: bool) -> Self {
if !feature_enabled {
Self::Hidden
} else if has_error && banner_dismissed {
Self::ErrorAlert
} else {
Self::OpenButton
}
}
}
/// Per-render-persistent handles for the footer. The `MouseStateHandle`s
/// back the three clickable surfaces and must be created once and reused
/// across renders per `WARP.md`; the scroll state handle serves the same
/// purpose for the error alert's scrollable text region.
#[derive(Clone, Default)]
pub struct SettingsFooterMouseStates {
pub open_settings_file_button: MouseStateHandle,
pub alert_open_file_button: MouseStateHandle,
pub alert_fix_with_oz_button: MouseStateHandle,
/// Scroll state for the error alert's text region (heading +
/// description), so scroll position survives renders.
pub alert_text_scroll_state: ClippedScrollStateHandle,
}
/// Renders the plain "Open settings file" button shown in the default state.
///
/// Visual spec (Figma `5655:62575`): 32px tall, full-width, 1px outlined,
/// 4px rounded corners, `code-02` leading icon, semibold label.
pub fn render_open_settings_file_button(
appearance: &Appearance,
mouse_state: MouseStateHandle,
) -> Box<dyn Element> {
let theme = appearance.theme();
let text_fill = theme.nonactive_ui_text_color();
let text_color = text_fill.into_solid();
let border_color = theme.outline().into_solid();
let ui_font_family = appearance.ui_font_family();
Hoverable::new(mouse_state, move |state| {
let icon = ConstrainedBox::new(Icon::Code2.to_warpui_icon(text_fill).finish())
.with_width(FOOTER_ICON_SIZE)
.with_height(FOOTER_ICON_SIZE)
.finish();
let label = Text::new_inline("Open settings file", ui_font_family, FOOTER_FONT_SIZE)
.with_color(text_color)
.with_style(Properties {
weight: Weight::Semibold,
..Default::default()
})
.finish();
// Use `MainAxisSize::Max` so the row (and its surrounding bordered
// container) expands to fill the full sidebar width. The icon + text
// are then centered inside that full-width row.
let row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Container::new(icon).with_margin_right(4.).finish())
.with_child(label)
.finish();
// Clip the row so the icon + label stay inside the bordered button
// when the sidebar (which is `Shrinkable` inside the workspace row)
// is resized narrower than the row's natural content width.
let mut container = Container::new(Clipped::new(row).finish())
.with_border(Border::all(1.).with_border_color(border_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if state.is_hovered() {
container = container.with_background_color(coloru_with_opacity(text_color, 10));
}
ConstrainedBox::new(container.finish())
.with_height(OPEN_BUTTON_HEIGHT)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(WorkspaceAction::OpenSettingsFile))
.finish()
}
/// Renders the inline yellow alert shown when the settings file has an error
/// and the workspace banner has been dismissed. Mirrors the workspace banner
/// messaging and actions.
pub fn render_settings_error_alert(
appearance: &Appearance,
error: &SettingsFileError,
ai_enabled: bool,
mouse_states: &SettingsFooterMouseStates,
) -> Box<dyn Element> {
let theme = appearance.theme();
// Warning banner colors: yellow background, contrast-safe text on top of
// the yellow. Same pattern as `Workspace::render_workspace_banner`.
let bg_color = theme.ansi_fg_yellow();
let text_color = theme.main_text_color(Fill::Solid(bg_color)).into_solid();
let ui_font_family = appearance.ui_font_family();
// ── Heading + description ────────────────────────────────────────────
// Copy is shared with `Workspace::render_settings_error_banner` via
// `SettingsFileError::heading_and_description` so the two UIs can't
// drift out of sync.
let (heading, description) = error.heading_and_description();
let heading_char_count = heading.chars().count();
let combined_text = format!("{heading} {description}");
// Soft-wrap (the `Text::new` default) is appropriate here since the
// alert's vertical space grows to fit the text.
let mut text_widget =
Text::new(combined_text, ui_font_family, FOOTER_FONT_SIZE).with_color(text_color);
if heading_char_count > 0 {
text_widget = text_widget.with_single_highlight(
Highlight::new().with_properties(Properties::default().weight(Weight::Semibold)),
(0..heading_char_count).collect(),
);
}
let alert_icon = ConstrainedBox::new(
Icon::AlertCircle
.to_warpui_icon(Fill::Solid(text_color))
.finish(),
)
.with_width(FOOTER_ICON_SIZE)
.with_height(FOOTER_ICON_SIZE)
.finish();
let text_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Container::new(alert_icon).with_margin_right(8.).finish())
.with_child(Expanded::new(1., text_widget.finish()).finish())
.finish();
// Cap the text region's height so verbose settings errors don't balloon
// the alert to fill the sidebar. Overflow scrolls within the alert; the
// action buttons below stay fixed and always actionable. Scrollbar thumb
// colors are derived from `text_color` (which already contrasts against
// the yellow alert background) so they remain visible in both themes.
// `ClippedScrollable` wants `warpui::elements::Fill` (not the theme
// `Fill` used elsewhere in this file), so the three fills below are
// fully qualified to avoid an import alias.
let scrollable_text = ConstrainedBox::new(
ClippedScrollable::vertical(
mouse_states.alert_text_scroll_state.clone(),
text_row,
ScrollbarWidth::Auto,
warpui::elements::Fill::Solid(coloru_with_opacity(text_color, 30)),
warpui::elements::Fill::Solid(coloru_with_opacity(text_color, 60)),
warpui::elements::Fill::None,
)
.finish(),
)
.with_max_height(ALERT_TEXT_MAX_HEIGHT)
.finish();
// ── Action buttons ───────────────────────────────────────────────────
let open_file_button = render_alert_action_button(
ui_font_family,
text_color,
mouse_states.alert_open_file_button.clone(),
"Open file",
/*icon=*/ None,
/*bordered=*/ true,
WorkspaceAction::OpenSettingsFile,
);
// Use a `Wrap` flex as a graceful fallback: if the sidebar is narrower
// than the buttons' combined natural width, they wrap onto a second
// row instead of pushing the alert container wider than the sidebar.
let mut buttons_row = Wrap::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_spacing(ALERT_BUTTON_SPACING)
.with_run_spacing(ALERT_BUTTON_SPACING)
.with_child(open_file_button);
if ai_enabled {
let error_description = error.to_string();
let fix_with_oz_button = render_alert_action_button(
ui_font_family,
text_color,
mouse_states.alert_fix_with_oz_button.clone(),
"Fix with Oz",
Some(Icon::Oz),
/*bordered=*/ false,
WorkspaceAction::FixSettingsWithOz { error_description },
);
buttons_row.add_child(fix_with_oz_button);
}
// ── Assemble ─────────────────────────────────────────────────────────
// Left-align the buttons with the start of the text (past the icon + gap).
let buttons_indented = Container::new(buttons_row.finish())
.with_margin_left(FOOTER_ICON_SIZE + 8.)
.with_margin_top(8.)
.finish();
// `CrossAxisAlignment::Stretch` tightens each child's cross-axis (width)
// constraint to the column's available width so the alert container
// doesn't end up sized by buttons overflowing their allotment.
let alert_body = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(scrollable_text)
.with_child(buttons_indented)
.finish();
Container::new(alert_body)
.with_background_color(bg_color)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_uniform_padding(12.)
.finish()
}
/// Wraps one of the three footer branches with the correct outer padding so
/// it aligns with the nav items above.
pub fn render_footer(
kind: SettingsFooterKind,
appearance: &Appearance,
error: Option<&SettingsFileError>,
ai_enabled: bool,
mouse_states: &SettingsFooterMouseStates,
) -> Box<dyn Element> {
let inner: Box<dyn Element> = match kind {
SettingsFooterKind::Hidden => return Empty::new().finish(),
SettingsFooterKind::OpenButton => render_open_settings_file_button(
appearance,
mouse_states.open_settings_file_button.clone(),
),
SettingsFooterKind::ErrorAlert => match error {
Some(error) => render_settings_error_alert(appearance, error, ai_enabled, mouse_states),
// Defensive fallback: if the error disappears between `choose` and
// `render_footer`, fall back to the plain button rather than
// rendering an empty alert shell.
None => render_open_settings_file_button(
appearance,
mouse_states.open_settings_file_button.clone(),
),
},
};
Container::new(inner)
.with_uniform_padding(FOOTER_PADDING)
.finish()
}
/// Renders a single action button inside the inline alert. Mirrors
/// `Workspace::render_banner_action_button` so the styling stays consistent
/// with the workspace-level banner.
fn render_alert_action_button(
ui_font_family: FamilyId,
text_color: ColorU,
mouse_state: MouseStateHandle,
text: &'static str,
icon: Option<Icon>,
bordered: bool,
action: WorkspaceAction,
) -> Box<dyn Element> {
Hoverable::new(mouse_state, move |state| {
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
if let Some(icon) = icon {
row.add_child(
Container::new(
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(text_color)).finish())
.with_width(ALERT_OZ_ICON_SIZE)
.with_height(ALERT_OZ_ICON_SIZE)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
}
row.add_child(
Text::new_inline(text.to_owned(), ui_font_family, FOOTER_FONT_SIZE)
.with_color(text_color)
.with_style(Properties {
weight: Weight::Semibold,
..Default::default()
})
.finish(),
);
let mut container = Container::new(row.finish())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_horizontal_padding(ALERT_BUTTON_HORIZONTAL_PADDING);
if bordered {
container = container.with_border(Border::all(1.).with_border_color(text_color));
}
if state.is_hovered() {
container = container.with_background_color(coloru_with_opacity(text_color, 20));
}
ConstrainedBox::new(container.finish())
.with_height(ALERT_ACTION_BUTTON_HEIGHT)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
.finish()
}
#[cfg(test)]
#[path = "settings_file_footer_tests.rs"]
mod tests;
@@ -0,0 +1,62 @@
use super::SettingsFooterKind;
// Hidden takes precedence over everything when the feature flag is off.
#[test]
fn feature_disabled_hides_footer_regardless_of_error_or_dismissal() {
assert_eq!(
SettingsFooterKind::choose(false, false, false),
SettingsFooterKind::Hidden
);
assert_eq!(
SettingsFooterKind::choose(false, true, false),
SettingsFooterKind::Hidden
);
assert_eq!(
SettingsFooterKind::choose(false, false, true),
SettingsFooterKind::Hidden
);
assert_eq!(
SettingsFooterKind::choose(false, true, true),
SettingsFooterKind::Hidden
);
}
// ErrorAlert only appears when BOTH an error is present AND the banner is
// dismissed. The workspace banner is still in charge otherwise.
#[test]
fn error_alert_shown_only_when_error_and_banner_dismissed() {
assert_eq!(
SettingsFooterKind::choose(true, true, true),
SettingsFooterKind::ErrorAlert
);
}
#[test]
fn error_present_but_banner_not_dismissed_shows_open_button() {
// User is still seeing the workspace banner at the top of the workspace,
// so the nav rail should just offer the plain button.
assert_eq!(
SettingsFooterKind::choose(true, true, false),
SettingsFooterKind::OpenButton
);
}
#[test]
fn no_error_but_banner_dismissed_shows_open_button() {
// `banner_dismissed` is sticky across error/no-error transitions in the
// workspace today — without an error, we still want the plain button.
assert_eq!(
SettingsFooterKind::choose(true, false, true),
SettingsFooterKind::OpenButton
);
}
#[test]
fn no_error_and_banner_not_dismissed_shows_open_button() {
assert_eq!(
SettingsFooterKind::choose(true, false, false),
SettingsFooterKind::OpenButton
);
}
File diff suppressed because it is too large Load Diff
+812
View File
@@ -0,0 +1,812 @@
use super::{
settings_page::{
render_page_title, MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget, HEADER_FONT_SIZE, PAGE_PADDING,
},
SettingsSection,
};
use crate::auth::AuthStateProvider;
use crate::{
appearance::Appearance,
channel::{Channel, ChannelState},
menu::{Event as MenuEvent, Event, Menu, MenuItem, MenuItemFields},
server::{block::Block, server_api::block::BlockClient},
view_components::ToastFlavor,
};
use anyhow::Result;
use chrono::{DateTime, FixedOffset, Local};
use pathfinder_geometry::vector::vec2f;
use std::sync::Arc;
use warp_core::ui::theme::color::internal_colors;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{
clipboard::ClipboardContent,
elements::{
Align, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Dismiss, Expanded, Fill, Flex, Hoverable, Icon, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor,
PositionedElementOffsetBounds, SavePosition, ScrollStateHandle, Scrollable,
ScrollableElement, Shrinkable, Stack, UniformList, UniformListState,
},
};
use warpui::{color::ColorU, elements::Radius};
use warpui::{elements::ScrollbarWidth, fonts::Weight};
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto;
const UNSHARE_BLOCK_CONFIRMATION_DIALOG_TEXT: &str =
"Are you sure you want to unshare this block?\n\
\nIt will no longer be accessible by link and will be permanently deleted from Warp servers.";
#[derive(Clone, Debug)]
struct UserOwnedBlock {
id: String,
command: String,
link_mouse_state_handle: MouseStateHandle,
copy_button_mouse_state_handle: MouseStateHandle,
overflow_button_mouse_state_handle: MouseStateHandle,
unshare_request_status: UnshareBlockRequestState,
time_started: DateTime<FixedOffset>,
}
impl From<Block> for UserOwnedBlock {
fn from(block: Block) -> Self {
UserOwnedBlock::new(
block.id.unwrap_or_default(),
block.command.unwrap_or_default(),
block.time_started_term,
)
}
}
impl UserOwnedBlock {
fn new(
id: impl Into<String>,
command: impl Into<String>,
time_started: DateTime<FixedOffset>,
) -> Self {
Self {
id: id.into(),
command: command.into(),
unshare_request_status: UnshareBlockRequestState::NotStarted,
link_mouse_state_handle: Default::default(),
copy_button_mouse_state_handle: Default::default(),
overflow_button_mouse_state_handle: Default::default(),
time_started,
}
}
fn is_shared(&self) -> bool {
!matches!(self.unshare_request_status, UnshareBlockRequestState::Done)
}
fn block_url(&self) -> String {
// New block IDs are 22 characters long and are accessible at /block/{id}, whereas as old
// (hashId) block IDs are 6 characters long and are accessible at /{id}.
let mut url = if self.id.len() == 22 {
format!(
"{}/block/{}",
ChannelState::server_root_url(),
self.id.as_str()
)
} else {
format!("{}/{}", ChannelState::server_root_url(), self.id.as_str())
};
// If this is a preview build, ensure the link routes to a preview build.
if matches!(ChannelState::channel(), Channel::Preview) {
url.push_str("?preview=true");
}
url
}
fn render_overflow_icon(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
let mut hoverable =
Hoverable::new(self.overflow_button_mouse_state_handle.clone(), |state| {
let container = Container::new(
ConstrainedBox::new(
Icon::new("bundled/svg/overflow.svg", ColorU::new(179, 186, 184, 255))
.finish(),
)
.with_height(20.)
.with_width(20.)
.finish(),
)
.with_uniform_padding(4.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(5.)));
let container = if state.is_clicked() || state.is_hovered() {
container.with_background(appearance.theme().surface_2())
} else {
container
};
container.finish()
});
// Disable the overflow button if the request is in flight since the user shouldn't be able
// to unshare it again.
if self.unshare_request_status == UnshareBlockRequestState::InFlight {
hoverable = hoverable.disable();
} else {
hoverable = hoverable.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ShowBlocksAction::OverflowClick(index));
});
};
SavePosition::new(
hoverable.finish(),
format!("show_blocks_view:overflow_{index}").as_str(),
)
.finish()
}
fn copy_link_button(&self, appearance: &Appearance, block_url: String) -> Box<dyn Element> {
let button = appearance
.ui_builder()
.button(
ButtonVariant::Basic,
self.copy_button_mouse_state_handle.clone(),
)
.with_text_label("Copy link".into());
let button = if self.unshare_request_status == UnshareBlockRequestState::InFlight {
button.disabled().build()
} else {
button.build().on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ShowBlocksAction::CopyUrl(block_url.clone()));
})
};
button.finish()
}
fn link_text(&self, appearance: &Appearance, block_url: String) -> Box<dyn Element> {
if self.unshare_request_status == UnshareBlockRequestState::InFlight {
appearance
.ui_builder()
.label("Deleting...")
.with_style(
UiComponentStyles::default()
.set_font_family_id(appearance.monospace_font_family())
.set_font_size(14.),
)
.build()
.finish()
} else {
appearance
.ui_builder()
.link(
block_url.clone(),
Some(block_url),
None,
self.link_mouse_state_handle.clone(),
)
.soft_wrap(false)
.with_style(UiComponentStyles::default().set_font_size(14.))
.build()
.finish()
}
}
fn render(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
let block_url = self.block_url();
let command = appearance
.ui_builder()
.label(self.command.clone())
.with_style(UiComponentStyles {
font_size: Some(14.),
font_family_id: Some(appearance.monospace_font_family()),
font_weight: Some(Weight::Bold),
..Default::default()
})
.build()
.finish();
let command_row = Container::new(
Flex::row()
.with_child(Shrinkable::new(1., Align::new(command).left().finish()).finish())
.with_child(
Container::new(self.render_overflow_icon(appearance, index))
.with_padding_left(15.)
.finish(),
)
.finish(),
)
.finish();
let url_row = Container::new(
Flex::row()
.with_child(
Shrinkable::new(
1.,
Container::new(self.link_text(appearance, block_url.clone())).finish(),
)
.finish(),
)
.with_child(
Shrinkable::new(
0.3,
Container::new(
Align::new(self.copy_link_button(appearance, block_url))
.right()
.finish(),
)
.finish(),
)
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
)
.finish();
let timestamp_row = Container::new(
appearance
.ui_builder()
.label(format!(
"Executed on: {}",
self.time_started
.with_timezone(&Local)
.format("%a, %b %-d %Y at %-I:%M %p")
))
.with_style(
UiComponentStyles::default()
.set_font_color(
appearance
.theme()
.hint_text_color(appearance.theme().surface_2())
.into(),
)
// make it slightly smaller than the url text
.set_font_size(appearance.ui_builder().ui_font_size() - 2.),
)
.build()
.finish(),
)
.finish();
Container::new(
ConstrainedBox::new(
Flex::column()
.with_child(Shrinkable::new(1.0, command_row).finish())
.with_child(Shrinkable::new(1., url_row).finish())
.with_child(timestamp_row)
.finish(),
)
.with_max_height(90.)
.finish(),
)
.finish()
}
}
/// Status for the request to fetch the blocks owned by the current user.
#[derive(Debug)]
enum GetBlocksForUserRequestState {
NotStarted,
InFlight,
Failed,
Done(Vec<UserOwnedBlock>),
}
fn pad(element: Box<dyn Element>) -> Box<dyn Element> {
Container::new(element)
.with_uniform_padding(PAGE_PADDING)
.finish()
}
impl GetBlocksForUserRequestState {
fn render(
&self,
appearance: &Appearance,
list_state: UniformListState,
scroll_state_handle: ScrollStateHandle,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
match self {
GetBlocksForUserRequestState::NotStarted => pad(ui_builder
.label("You don't have any shared blocks yet.")
.build()
.finish()),
GetBlocksForUserRequestState::InFlight => {
pad(ui_builder.label("Getting blocks...").build().finish())
}
GetBlocksForUserRequestState::Failed => pad(ui_builder
.label("Failed to load blocks. Please try again.")
.build()
.finish()),
GetBlocksForUserRequestState::Done(user_blocks) => {
let user_blocks = user_blocks.clone();
// Only consider the unshared blocks when rendering. We don't remove them from the
// list of blocks so that the indexing is consistent for the lifetime of the app.
let num_visible_blocks =
user_blocks.iter().filter(|block| block.is_shared()).count();
if num_visible_blocks > 0 {
let list =
UniformList::new(list_state, num_visible_blocks, move |range, app| {
let appearance = Appearance::as_ref(app);
user_blocks
.iter()
.enumerate()
.filter(|(_, block)| block.is_shared())
.skip(range.start)
.take(range.end - range.start)
.enumerate()
.map(|(visible_index, (index, user_block))| {
let user_block_element =
Container::new(user_block.render(appearance, index))
.with_uniform_padding(10.);
// Add a background on alternating blocks.
if visible_index % 2 == 0 {
user_block_element
.with_background(internal_colors::fg_overlay_1(
appearance.theme(),
))
.finish()
} else {
user_block_element.finish()
}
})
.collect::<Vec<_>>()
.into_iter()
});
Scrollable::vertical(
scroll_state_handle,
list.finish_scrollable(),
SCROLLBAR_WIDTH,
Fill::Solid(appearance.theme().nonactive_ui_detail().into_solid()),
Fill::Solid(appearance.theme().active_ui_detail().into_solid()),
Fill::None, // Leave the background transparent
)
.with_padding_start(5.)
.finish()
} else {
pad(ui_builder
.label("You don't have any shared blocks yet.")
.build()
.finish())
}
}
}
}
}
/// Status for the request to unshare a block.
#[derive(Debug, Clone, PartialEq)]
enum UnshareBlockRequestState {
NotStarted,
InFlight,
Failed,
Done,
}
#[derive(Default)]
struct StateHandles {
scroll_state_handle: ScrollStateHandle,
confirm_dialog_handle: MouseStateHandle,
cancel_dialog_handle: MouseStateHandle,
}
/// A view that lists all the blocks owned by the user.
pub struct ShowBlocksView {
page: PageType<Self>,
list_state: UniformListState,
overflow_menu: ViewHandle<Menu<ShowBlocksAction>>,
overflow_menu_index: Option<usize>,
get_blocks_for_user_status: GetBlocksForUserRequestState,
pending_unshared_block_index: Option<usize>,
block_client: Arc<dyn BlockClient>,
state_handles: StateHandles,
}
#[derive(Debug, Clone)]
pub enum ShowBlocksAction {
CopyUrl(String),
OverflowClick(usize),
Unshare,
ConfirmUnshare,
CancelUnshare,
}
#[derive(Debug, Clone)]
pub enum ShowBlocksEvent {
ShowToast {
message: String,
flavor: ToastFlavor,
},
}
impl ShowBlocksView {
pub fn new(block_client: Arc<dyn BlockClient>, ctx: &mut ViewContext<Self>) -> Self {
let menu = ctx.add_typed_action_view(|ctx| {
let mut menu = Menu::new().prevent_interaction_with_other_elements();
menu.set_items(
vec![MenuItem::Item(
MenuItemFields::new("Unshare").with_on_select_action(ShowBlocksAction::Unshare),
)],
ctx,
);
menu
});
ctx.subscribe_to_view(&menu, move |me, _, event, ctx| {
me.handle_overflow_menu_event(event, ctx);
});
let page = PageType::new_monolith(ShowBlocksWidget::default(), None, false);
Self {
page,
list_state: Default::default(),
state_handles: Default::default(),
overflow_menu: menu,
overflow_menu_index: None,
get_blocks_for_user_status: GetBlocksForUserRequestState::NotStarted,
block_client,
pending_unshared_block_index: None,
}
}
fn load_blocks(&mut self, ctx: &mut ViewContext<Self>) {
if !matches!(
self.get_blocks_for_user_status,
GetBlocksForUserRequestState::InFlight
) {
let block_client = self.block_client.clone();
self.get_blocks_for_user_status = GetBlocksForUserRequestState::InFlight;
let _ = ctx.spawn(
async move {
block_client
.blocks_owned_by_user()
.await
.map(|blocks| blocks.into_iter().map(Into::into).collect())
},
Self::on_load_complete,
);
}
}
fn on_load_complete(
&mut self,
result: Result<Vec<UserOwnedBlock>>,
ctx: &mut ViewContext<Self>,
) {
match result {
Ok(mut blocks) => {
blocks.sort_by(|b1, b2| b2.time_started.cmp(&b1.time_started));
self.get_blocks_for_user_status = GetBlocksForUserRequestState::Done(blocks)
}
Err(_) => {
log::info!("Failed to fetch blocks owned by user from server");
self.get_blocks_for_user_status = GetBlocksForUserRequestState::Failed;
}
}
ctx.notify()
}
pub fn copy_url(&mut self, block_url: &str, ctx: &mut ViewContext<Self>) {
ctx.clipboard()
.write(ClipboardContent::plain_text(block_url.to_string()));
ctx.emit(ShowBlocksEvent::ShowToast {
message: "Link copied.".to_string(),
flavor: ToastFlavor::Default,
})
}
pub fn overflow_click(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
self.overflow_menu_index = Some(index);
ctx.notify();
}
pub fn cancel_unshare(&mut self, ctx: &mut ViewContext<Self>) {
self.pending_unshared_block_index = None;
ctx.notify();
}
pub fn confirm_unshare(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(selected_index) = self.pending_unshared_block_index.take() {
if let GetBlocksForUserRequestState::Done(blocks) = &mut self.get_blocks_for_user_status
{
// Only attempt to unshare if there isn't already an inflight request to unshare
// the block.
let user_block = &mut blocks[selected_index];
if !matches!(
user_block.unshare_request_status,
UnshareBlockRequestState::InFlight
) {
user_block.unshare_request_status = UnshareBlockRequestState::InFlight;
let block_client = self.block_client.clone();
let block_id = user_block.id.clone();
let _ = ctx.spawn(
async move { (block_client.unshare_block(block_id).await, selected_index) },
Self::on_block_unshare_complete,
);
}
}
}
ctx.notify();
}
pub fn unshare_button_click(&mut self, ctx: &mut ViewContext<Self>) {
self.pending_unshared_block_index = self.overflow_menu_index.take();
ctx.notify();
}
fn on_block_unshare_complete(
&mut self,
(request_result, block_index): (Result<()>, usize),
ctx: &mut ViewContext<Self>,
) {
log::info!(
"on_block_unshare_complete with result {:?}",
&request_result
);
if let GetBlocksForUserRequestState::Done(blocks) = &mut self.get_blocks_for_user_status {
let user_block = &mut blocks[block_index];
match request_result {
Ok(_) => {
ctx.emit(ShowBlocksEvent::ShowToast {
message: "Block was successfully unshared.".to_string(),
flavor: ToastFlavor::Success,
});
user_block.unshare_request_status = UnshareBlockRequestState::Done;
}
Err(_) => {
ctx.emit(ShowBlocksEvent::ShowToast {
message: "Failed to unshare block. Please try again.".to_string(),
flavor: ToastFlavor::Error,
});
user_block.unshare_request_status = UnshareBlockRequestState::Failed;
}
}
ctx.notify();
}
}
fn handle_overflow_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
if let Event::Close { via_select_item: _ } = event {
self.overflow_menu_index = None;
ctx.notify();
}
}
}
impl Entity for ShowBlocksView {
type Event = ShowBlocksEvent;
}
impl TypedActionView for ShowBlocksView {
type Action = ShowBlocksAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
use ShowBlocksAction::*;
match action {
CopyUrl(url) => self.copy_url(url, ctx),
OverflowClick(index) => self.overflow_click(*index, ctx),
Unshare => self.unshare_button_click(ctx),
ConfirmUnshare => self.confirm_unshare(ctx),
CancelUnshare => self.cancel_unshare(ctx),
}
}
}
impl View for ShowBlocksView {
fn ui_name() -> &'static str {
"ShowBlockView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
}
}
impl SettingsPageMeta for ShowBlocksView {
fn section() -> SettingsSection {
SettingsSection::SharedBlocks
}
fn should_render(&self, ctx: &AppContext) -> bool {
let is_anonymous = AuthStateProvider::as_ref(ctx)
.get()
.is_anonymous_or_logged_out();
!is_anonymous
}
fn on_page_selected(&mut self, _: bool, ctx: &mut ViewContext<Self>) {
self.load_blocks(ctx);
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
self.page.update_filter(query, ctx)
}
fn scroll_to_widget(&mut self, widget_id: &'static str) {
self.page.scroll_to_widget(widget_id)
}
fn clear_highlighted_widget(&mut self) {
self.page.clear_highlighted_widget();
}
}
impl From<ViewHandle<ShowBlocksView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<ShowBlocksView>) -> Self {
SettingsPageViewHandle::SharedBlocks(view_handle)
}
}
#[derive(Default)]
struct ShowBlocksWidget {}
impl ShowBlocksWidget {
fn render_confirm_delete_block_dialog(
&self,
view: &ShowBlocksView,
appearance: &Appearance,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
ConstrainedBox::new(
Container::new(
Flex::column()
.with_child(
Align::new(
ui_builder
.label("Unshare block")
.with_style(UiComponentStyles {
font_size: Some(appearance.header_font_size()),
..Default::default()
})
.build()
.finish(),
)
.top_center()
.finish(),
)
.with_child(
Container::new(
ui_builder
.paragraph(UNSHARE_BLOCK_CONFIRMATION_DIALOG_TEXT)
.with_style(UiComponentStyles {
font_size: Some(appearance.ui_font_size() * 1.16),
..Default::default()
})
.build()
.finish(),
)
.with_padding_top(9.)
.finish(),
)
.with_child(
Container::new(
Align::new(
Flex::row()
.with_child(
ui_builder
.button(
ButtonVariant::Basic,
view.state_handles.cancel_dialog_handle.clone(),
)
.with_text_label("Cancel".into())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ShowBlocksAction::CancelUnshare,
);
})
.finish(),
)
.with_child(
Container::new(
ui_builder
.button(
ButtonVariant::Accent,
view.state_handles
.confirm_dialog_handle
.clone(),
)
.with_text_label("Unshare".into())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ShowBlocksAction::ConfirmUnshare,
);
})
.finish(),
)
.with_padding_left(10.)
.finish(),
)
.finish(),
)
.top_center()
.finish(),
)
.with_padding_top(20.)
.finish(),
)
.finish(),
)
.with_background(appearance.theme().surface_2())
.with_uniform_padding(20.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish(),
)
.with_height(220.)
.with_width(300.)
.finish()
}
}
impl SettingsWidget for ShowBlocksWidget {
type View = ShowBlocksView;
fn search_terms(&self) -> &str {
"shared blocks"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn Element> {
let element_for_state = view.get_blocks_for_user_status.render(
appearance,
view.list_state.clone(),
view.state_handles.scroll_state_handle.clone(),
);
let inner_container = SavePosition::new(
Container::new(element_for_state).finish(),
"show_blocks_view:modal",
)
.finish();
let mut stack = Stack::new().with_child(inner_container);
if let Some(menu_index) = view.overflow_menu_index {
stack.add_positioned_overlay_child(
ChildView::new(&view.overflow_menu).finish(),
OffsetPositioning::offset_from_save_position_element(
format!("show_blocks_view:overflow_{menu_index}").as_str(),
vec2f(0., 2.),
PositionedElementOffsetBounds::Unbounded,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
}
if view.pending_unshared_block_index.is_some() {
stack.add_positioned_child(
Dismiss::new(
Align::new(self.render_confirm_delete_block_dialog(view, appearance)).finish(),
)
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(ShowBlocksAction::CancelUnshare))
.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
let header = render_page_title("Shared blocks", HEADER_FONT_SIZE, appearance);
let col = Flex::column()
.with_child(Container::new(header).with_margin_bottom(24.).finish())
.with_child(Expanded::new(1., stack.finish()).finish());
col.finish()
}
}
+58
View File
@@ -0,0 +1,58 @@
use std::fmt::Display;
use warpui::ui_components::button::ButtonVariant;
use super::teams_page::TeamsPageAction;
use crate::cloud_object::model::persistence::CloudModel;
use crate::workspaces::team::Team;
use crate::Appearance;
use warpui::elements::MouseStateHandle;
use warpui::ui_components::components::UiComponent;
use warpui::ui_components::components::UiComponentStyles;
use warpui::Element;
/// The Tabs trait provides common functionality for an enum to be used as a tabs menu UI component.
/// It requires the trait-user to implement action_on_click() and label().
pub trait Tabs: PartialEq + Display + Copy {
#[allow(dead_code)]
fn button_variant(&self, selected_view_option: &Self) -> ButtonVariant {
if self == selected_view_option {
ButtonVariant::Basic
} else {
ButtonVariant::Outlined
}
}
fn tab_name(&self) -> String {
self.to_string()
}
#[allow(dead_code)]
fn render_tab(
&self,
team: &Team,
cloud_model: &CloudModel,
selected_view_option: &Self,
mouse_state_handle: MouseStateHandle,
appearance: &Appearance,
) -> Box<dyn Element> {
let action = self.action_on_click(*self);
appearance
.ui_builder()
.button(
self.button_variant(selected_view_option),
mouse_state_handle,
)
.with_text_label(self.label(team, cloud_model))
.with_style(UiComponentStyles::default().set_border_width(0.))
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
.finish()
}
// The trait-inheriter must define their own action and their own labels.
#[allow(dead_code)]
fn action_on_click(&self, selection: Self) -> TeamsPageAction;
#[allow(dead_code)]
fn label(&self, team: &Team, cloud_model: &CloudModel) -> String;
}
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
use serde_json::Value;
use strum_macros::EnumDiscriminants;
use strum_macros::EnumIter;
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub enum SettingsTelemetryEvent {
EnvironmentsPageOpened,
}
impl TelemetryEvent for SettingsTelemetryEvent {
fn name(&self) -> &'static str {
SettingsTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<Value> {
None
}
fn description(&self) -> &'static str {
SettingsTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
SettingsTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
match self {
SettingsTelemetryEvent::EnvironmentsPageOpened => false,
}
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
warp_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for SettingsTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
SettingsTelemetryEventDiscriminants::EnvironmentsPageOpened => {
"Settings.Environments.PageOpened"
}
}
}
fn description(&self) -> &'static str {
match self {
SettingsTelemetryEventDiscriminants::EnvironmentsPageOpened => {
"User opened the Environments settings page"
}
}
}
fn enablement_state(&self) -> EnablementState {
match self {
SettingsTelemetryEventDiscriminants::EnvironmentsPageOpened => EnablementState::Always,
}
}
}
warp_core::register_telemetry_event!(SettingsTelemetryEvent);
@@ -0,0 +1,154 @@
use warpui::{
elements::{Align, Container, CrossAxisAlignment, Flex, MouseStateHandle, ParentElement, Text},
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::appearance::Appearance;
use crate::auth::UserUid;
use crate::server::ids::ServerId;
pub struct TransferOwnershipConfirmationModal {
cancel_mouse_state: MouseStateHandle,
confirm_mouse_state: MouseStateHandle,
new_owner_email: Option<String>,
new_owner_uid: Option<UserUid>,
team_uid: Option<ServerId>,
}
impl TransferOwnershipConfirmationModal {
pub fn new() -> Self {
Self {
cancel_mouse_state: Default::default(),
confirm_mouse_state: Default::default(),
new_owner_email: None,
new_owner_uid: None,
team_uid: None,
}
}
pub fn set_new_owner(&mut self, email: String, user_uid: UserUid, team_uid: ServerId) {
self.new_owner_email = Some(email);
self.new_owner_uid = Some(user_uid);
self.team_uid = Some(team_uid);
}
}
impl Entity for TransferOwnershipConfirmationModal {
type Event = TransferOwnershipConfirmationEvent;
}
impl View for TransferOwnershipConfirmationModal {
fn ui_name() -> &'static str {
"TransferOwnershipConfirmationModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let email = self.new_owner_email.as_deref().unwrap_or_default();
let description_text = Text::new(
format!(
"Are you sure you want to transfer team ownership to {}? You will no longer be the owner and will not be able to take any administrative actions for this team.",
email
),
appearance.ui_font_family(),
14.,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish();
let button_style = UiComponentStyles {
font_size: Some(14.),
padding: Some(Coords::uniform(8.).left(12.).right(12.)),
..Default::default()
};
let buttons_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
appearance
.ui_builder()
.button(ButtonVariant::Secondary, self.cancel_mouse_state.clone())
.with_text_label("Cancel".to_string())
.with_style(button_style)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(TransferOwnershipConfirmationAction::Cancel);
})
.finish(),
)
.with_child(
Container::new(
appearance
.ui_builder()
.button(ButtonVariant::Accent, self.confirm_mouse_state.clone())
.with_text_label("Transfer".to_string())
.with_style(button_style)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(TransferOwnershipConfirmationAction::Confirm);
})
.finish(),
)
.with_margin_left(12.)
.finish(),
)
.finish();
Flex::column()
.with_child(
Container::new(description_text)
.with_margin_bottom(24.)
.finish(),
)
.with_child(Align::new(buttons_row).right().finish())
.finish()
}
}
pub enum TransferOwnershipConfirmationEvent {
Confirm {
new_owner_uid: UserUid,
team_uid: ServerId,
},
Cancel,
}
#[derive(Debug)]
pub enum TransferOwnershipConfirmationAction {
Confirm,
Cancel,
}
impl TypedActionView for TransferOwnershipConfirmationModal {
type Action = TransferOwnershipConfirmationAction;
fn handle_action(
&mut self,
action: &TransferOwnershipConfirmationAction,
ctx: &mut ViewContext<Self>,
) {
match action {
TransferOwnershipConfirmationAction::Confirm => {
let (Some(new_owner_uid), Some(team_uid)) = (self.new_owner_uid, self.team_uid)
else {
log::error!("Transfer ownership confirm button pressed with no new owner set");
return;
};
ctx.emit(TransferOwnershipConfirmationEvent::Confirm {
new_owner_uid,
team_uid,
});
}
TransferOwnershipConfirmationAction::Cancel => {
ctx.emit(TransferOwnershipConfirmationEvent::Cancel);
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
use super::{
settings_page::{
render_body_item, AdditionalInfo, MatchData, PageType, SettingsPageMeta,
SettingsPageViewHandle, SettingsWidget,
},
LocalOnlyIconState, SettingsSection, ToggleState,
};
use crate::{appearance::Appearance, auth::AuthStateProvider, drive::settings::WarpDriveSettings};
use warp_core::{features::FeatureFlag, report_if_error, settings::ToggleableSetting as _};
use warpui::{
elements::{Container, Element, Flex, MouseStateHandle, ParentElement, Shrinkable, Text},
fonts::Weight,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
#[derive(Debug, Clone)]
pub enum WarpDriveSettingsPageAction {
ToggleShowWarpDrive,
SignUp,
OpenUrl(String),
}
pub enum WarpDriveSettingsPageEvent {
SignUp,
}
pub struct WarpDriveSettingsPageView {
page: PageType<Self>,
}
impl WarpDriveSettingsPageView {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self {
page: PageType::new_uncategorized(
vec![
Box::new(WarpDriveHeaderWidget::default()),
Box::new(WarpDriveToggleWidget::default()),
],
None,
),
}
}
}
impl Entity for WarpDriveSettingsPageView {
type Event = WarpDriveSettingsPageEvent;
}
impl TypedActionView for WarpDriveSettingsPageView {
type Action = WarpDriveSettingsPageAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
WarpDriveSettingsPageAction::ToggleShowWarpDrive => {
WarpDriveSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.enable_warp_drive.toggle_and_save_value(ctx));
});
ctx.notify();
}
WarpDriveSettingsPageAction::SignUp => {
ctx.emit(WarpDriveSettingsPageEvent::SignUp);
}
WarpDriveSettingsPageAction::OpenUrl(url) => {
ctx.open_url(url.as_str());
}
}
}
}
impl View for WarpDriveSettingsPageView {
fn ui_name() -> &'static str {
"WarpDrivePage"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
}
}
impl SettingsPageMeta for WarpDriveSettingsPageView {
fn section() -> SettingsSection {
SettingsSection::WarpDrive
}
fn should_render(&self, _ctx: &AppContext) -> bool {
FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
self.page.update_filter(query, ctx)
}
fn scroll_to_widget(&mut self, widget_id: &'static str) {
self.page.scroll_to_widget(widget_id)
}
fn clear_highlighted_widget(&mut self) {
self.page.clear_highlighted_widget();
}
}
impl From<ViewHandle<WarpDriveSettingsPageView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<WarpDriveSettingsPageView>) -> Self {
SettingsPageViewHandle::WarpDrive(view_handle)
}
}
#[derive(Default)]
struct WarpDriveHeaderWidget {
sign_up_button: MouseStateHandle,
}
impl SettingsWidget for WarpDriveHeaderWidget {
type View = WarpDriveSettingsPageView;
fn search_terms(&self) -> &str {
"warp drive sign up"
}
fn should_render(&self, app: &AppContext) -> bool {
FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
&& AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out()
}
fn render(
&self,
_view: &Self::View,
appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
let message = Container::new(
Text::new_inline(
"To use Warp Drive, please create an account.".to_string(),
appearance.ui_font_family(),
14.,
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
.into_solid(),
)
.finish(),
)
.with_margin_right(16.)
.finish();
let button = Container::new(
ui_builder
.button(ButtonVariant::Accent, self.sign_up_button.clone())
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Semibold),
border_radius: Some(warpui::elements::CornerRadius::with_all(
warpui::elements::Radius::Pixels(4.),
)),
padding: Some(Coords {
top: 8.,
bottom: 8.,
left: 24.,
right: 24.,
}),
..Default::default()
})
.with_text_label("Sign up".to_owned())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WarpDriveSettingsPageAction::SignUp);
})
.finish(),
)
.finish();
Container::new(
Flex::row()
.with_cross_axis_alignment(warpui::elements::CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., message).finish())
.with_child(button)
.finish(),
)
.with_padding_bottom(15.)
.finish()
}
}
#[derive(Default)]
struct WarpDriveToggleWidget {
switch_state: SwitchStateHandle,
info_icon_mouse_state: MouseStateHandle,
}
impl SettingsWidget for WarpDriveToggleWidget {
type View = WarpDriveSettingsPageView;
fn search_terms(&self) -> &str {
"warp drive tools panel command palette search workflows prompts notebooks environment variables"
}
fn render(
&self,
_view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let settings = WarpDriveSettings::as_ref(app);
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
&& AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out();
render_body_item::<WarpDriveSettingsPageAction>(
"Warp Drive".into(),
Some(AdditionalInfo {
mouse_state: self.info_icon_mouse_state.clone(),
on_click_action: Some(WarpDriveSettingsPageAction::OpenUrl(
"https://docs.warp.dev/knowledge-and-collaboration/warp-drive".to_string(),
)),
secondary_text: None,
tooltip_override_text: None,
}),
LocalOnlyIconState::Hidden,
if is_anonymous_or_logged_out {
ToggleState::Disabled
} else {
ToggleState::Enabled
},
appearance,
appearance
.ui_builder()
.switch(self.switch_state.clone())
.check(*settings.enable_warp_drive && !is_anonymous_or_logged_out)
.with_disabled(is_anonymous_or_logged_out)
.build()
.on_click(move |ctx, _, _| {
if !is_anonymous_or_logged_out {
ctx.dispatch_typed_action(
WarpDriveSettingsPageAction::ToggleShowWarpDrive,
);
}
})
.finish(),
Some("Warp Drive is a workspace in your terminal where you can save Workflows, Notebooks, Prompts, and Environment Variables for personal use or to share with a team.".into()),
)
}
}
+837
View File
@@ -0,0 +1,837 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::fmt::Display;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use regex::Regex;
use settings::{Setting, ToggleableSetting};
use strum::IntoEnumIterator;
use warp_core::features::FeatureFlag;
use warpui::elements::{FormattedTextElement, HighlightedHyperlink};
use warpui::keymap::ContextPredicate;
use warpui::{
elements::{Container, Flex, MouseStateHandle, ParentElement},
presenter::ChildView,
ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use crate::terminal::warpify::settings::{
EnableSshWarpification, SshExtensionInstallMode, UseSshTmuxWrapper, WarpifySettingsChangedEvent,
};
use crate::ui_components::blended_colors;
use crate::{
appearance::Appearance,
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
terminal::warpify::settings::WarpifySettings,
view_components::{SubmittableTextInput, SubmittableTextInputEvent},
};
use super::settings_page::{
render_body_item, render_dropdown_item, render_page_title, AdditionalInfo, Category,
LocalOnlyIconState, MatchData, PageType, SettingsPageEvent, SettingsWidget, ToggleState,
HEADER_FONT_SIZE, HEADER_PADDING,
};
use super::SettingsSection;
use super::{
flags,
settings_page::{
add_setting, render_alternating_color_list, SettingsPageMeta, SettingsPageViewHandle,
},
SettingsAction, ToggleSettingActionPair,
};
use crate::view_components::dropdown::{Dropdown, DropdownItem};
pub fn init_actions_from_parent_view<T: Action + Clone>(
app: &mut AppContext,
context: &ContextPredicate,
builder: fn(SettingsAction) -> T,
) {
// Add all of the toggle settings from the Warpify Page that you want to show up on the Command Palette here.
let mut toggle_binding_pairs = vec![];
if FeatureFlag::SSHTmuxWrapper.is_enabled() {
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"SSH session detection for Warpification",
builder(SettingsAction::WarpifyPageToggle(
WarpifyPageAction::ToggleTmuxWarpification,
)),
context,
flags::SSH_TMUX_WRAPPER_CONTEXT_FLAG,
));
}
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(toggle_binding_pairs, app);
}
const CONTENT_FONT_SIZE: f32 = 12.;
const ITEM_VERTICAL_SPACING: f32 = 24.;
/// There's a built-in 10px margin below the text input.
const BUILT_IN_TEXT_INPUT_MARGIN: f32 = 10.;
const SPACE_AFTER_TEXT_INPUT: f32 = ITEM_VERTICAL_SPACING - BUILT_IN_TEXT_INPUT_MARGIN;
const SSH_TMUX_WARPIFICATION_DESCRIPTION: &str = "The tmux ssh wrapper works in many situations where the default one does not, but may require you to hit a button to warpify. Takes effect in new tabs.";
const SSH_EXTENSION_INSTALL_MODE_DESCRIPTION: &str =
"Controls the installation behavior for Warp's SSH extension when a remote host doesn't have it installed.";
/// This page lets users configure when they get asked to warpify a session. Some shell commands
/// are recognized by default. Users can add new shell commands, or prevent the default ones from
/// asking. Users can also enable the SSH wrapper, and add hosts to a denylist.
/// This page is essentially the View for the SubshellSettings model, as well as the SshSettings
/// related to warpification.
pub struct WarpifyPageView {
page: PageType<Self>,
/// This needs to mirror the length of SubshellSettings::added_remove_button_states.
remove_added_command_button_states: Vec<MouseStateHandle>,
add_added_commands_editor: ViewHandle<SubmittableTextInput>,
/// This needs to mirror the length of SubshellSettings::denylisted_remove_button_states.
remove_denylisted_command_button_states: Vec<MouseStateHandle>,
add_denylisted_commands_editor: ViewHandle<SubmittableTextInput>,
remove_denylisted_ssh_button_states: Vec<MouseStateHandle>,
add_denylisted_ssh_editor: ViewHandle<SubmittableTextInput>,
ssh_extension_install_mode_dropdown: ViewHandle<Dropdown<WarpifyPageAction>>,
}
impl WarpifyPageView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let warpify_settings_handle = WarpifySettings::handle(ctx);
ctx.observe(&warpify_settings_handle, Self::update_button_states);
ctx.subscribe_to_model(&warpify_settings_handle, move |me, model, event, ctx| {
me.update_button_states(model, ctx);
if matches!(
event,
WarpifySettingsChangedEvent::SshExtensionInstallMode { .. }
) {
me.update_dropdown(ctx);
}
ctx.notify();
});
// Added commands can be specified by regex, while denied commands are strictly exact
// match.
let add_added_commands_editor = ctx.add_typed_action_view(|ctx| {
let mut input =
SubmittableTextInput::new(ctx).validate_on_edit(|regex| Regex::new(regex).is_ok());
input.set_placeholder_text("command (supports regex)", ctx);
input
});
ctx.subscribe_to_view(
&add_added_commands_editor,
Self::handle_added_command_editor_event,
);
let add_denylisted_commands_editor = ctx.add_typed_action_view(|ctx| {
let mut input = SubmittableTextInput::new(ctx);
input.set_placeholder_text("command (supports regex)", ctx);
input
});
ctx.subscribe_to_view(
&add_denylisted_commands_editor,
Self::handle_denylisted_command_editor_event,
);
let add_denylisted_ssh_editor = ctx.add_typed_action_view(|ctx| {
let mut input = SubmittableTextInput::new(ctx);
input.set_placeholder_text("host (supports regex)", ctx);
input
});
ctx.subscribe_to_view(
&add_denylisted_ssh_editor,
Self::handle_denylisted_ssh_editor_event,
);
let ssh_extension_install_mode_dropdown =
Self::create_ssh_extension_install_mode_dropdown(ctx);
let mut instance = Self {
page: Self::build_page(ctx),
remove_added_command_button_states: Default::default(),
add_added_commands_editor,
remove_denylisted_command_button_states: Default::default(),
add_denylisted_commands_editor,
remove_denylisted_ssh_button_states: Default::default(),
add_denylisted_ssh_editor,
ssh_extension_install_mode_dropdown,
};
instance.update_button_states(warpify_settings_handle, ctx);
instance
}
fn build_page(ctx: &mut ViewContext<Self>) -> PageType<Self> {
let mut categories = vec![
Category::new("", vec![Box::new(TitleWidget::default())]),
Category::new("Subshells", vec![Box::new(SubshellsWidget::default())])
.with_subtitle("Subshells supported: bash, zsh, and fish."),
];
let warpify_settings = WarpifySettings::as_ref(ctx);
if FeatureFlag::SSHTmuxWrapper.is_enabled()
&& warpify_settings
.enable_ssh_warpification
.is_supported_on_current_platform()
{
categories.push(
Category::new("SSH", vec![Box::new(SSHWidget::default())])
.with_subtitle("Warpify your interactive SSH sessions."),
);
}
PageType::new_categorized(categories, None)
}
/// This method ensures each command in the SubshellSettings has a matching button state for
/// its delete button in the View.
fn update_button_states(
&mut self,
warpify_settings_handle: ModelHandle<WarpifySettings>,
ctx: &mut ViewContext<Self>,
) {
let warpify_settings = warpify_settings_handle.as_ref(ctx);
self.remove_denylisted_command_button_states = warpify_settings
.subshell_command_denylist
.iter()
.map(|_| Default::default())
.collect();
self.remove_added_command_button_states = warpify_settings
.added_subshell_commands
.iter()
.map(|_| Default::default())
.collect();
self.remove_denylisted_ssh_button_states = warpify_settings
.ssh_hosts_denylist
.iter()
.map(|_| Default::default())
.collect();
ctx.notify();
}
/// Syncs the install-mode dropdown selection with the current
/// `WarpifySettings::ssh_extension_install_mode` value (e.g. after it
/// was changed from the SSH remote server choice view).
fn update_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
let current_mode = *WarpifySettings::as_ref(ctx)
.ssh_extension_install_mode
.value();
self.ssh_extension_install_mode_dropdown
.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_action(
WarpifyPageAction::SetSshExtensionInstallMode(current_mode),
ctx,
);
});
}
fn handle_added_command_editor_event(
&mut self,
_handle: ViewHandle<SubmittableTextInput>,
event: &SubmittableTextInputEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SubmittableTextInputEvent::Submit(new_command) => {
WarpifySettings::handle(ctx).update(ctx, |warpify_settings, ctx| {
warpify_settings.add_subshell_command(new_command, ctx);
});
send_telemetry_from_ctx!(TelemetryEvent::AddAddedSubshellCommand, ctx);
}
SubmittableTextInputEvent::Escape => ctx.emit(SettingsPageEvent::FocusModal),
}
}
fn handle_denylisted_command_editor_event(
&mut self,
_handle: ViewHandle<SubmittableTextInput>,
event: &SubmittableTextInputEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SubmittableTextInputEvent::Submit(new_command) => {
WarpifySettings::handle(ctx).update(ctx, |warpify_settings, ctx| {
warpify_settings.denylist_subshell_command(new_command, ctx);
});
send_telemetry_from_ctx!(TelemetryEvent::AddDenylistedSubshellCommand, ctx);
}
SubmittableTextInputEvent::Escape => ctx.emit(SettingsPageEvent::FocusModal),
}
}
fn handle_denylisted_ssh_editor_event(
&mut self,
_handle: ViewHandle<SubmittableTextInput>,
event: &SubmittableTextInputEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SubmittableTextInputEvent::Submit(new_command) => {
WarpifySettings::handle(ctx).update(ctx, |warpify_settings, ctx| {
warpify_settings.denylist_ssh_host(new_command, ctx);
});
send_telemetry_from_ctx!(TelemetryEvent::AddDenylistedSshTmuxWrapperHost, ctx);
}
SubmittableTextInputEvent::Escape => ctx.emit(SettingsPageEvent::FocusModal),
}
}
fn remove_denylisted_command(&self, index: usize, ctx: &mut ViewContext<Self>) {
send_telemetry_from_ctx!(TelemetryEvent::RemoveDenylistedSubshellCommand, ctx);
WarpifySettings::handle(ctx).update(ctx, |warpify, ctx| {
warpify.remove_denylisted_subshell_command(index, ctx)
});
}
fn remove_added_command(&self, index: usize, ctx: &mut ViewContext<Self>) {
send_telemetry_from_ctx!(TelemetryEvent::RemoveAddedSubshellCommand, ctx);
WarpifySettings::handle(ctx).update(ctx, |warpify, ctx| {
warpify.remove_added_subshell_command(index, ctx)
});
}
fn remove_denylisted_ssh_host(&self, index: usize, ctx: &mut ViewContext<Self>) {
send_telemetry_from_ctx!(TelemetryEvent::RemoveDenylistedSshTmuxWrapperHost, ctx);
WarpifySettings::handle(ctx).update(ctx, |warpify, ctx| {
warpify.remove_denylisted_ssh_host(index, ctx)
});
}
}
impl Entity for WarpifyPageView {
type Event = SettingsPageEvent;
}
fn build_sub_sub_title(title: &str, appearance: &Appearance) -> Container {
appearance
.ui_builder()
.span(title.to_string())
.with_style(UiComponentStyles {
font_size: Some(CONTENT_FONT_SIZE),
..Default::default()
})
.build()
}
const SSH_EXTENSION_DROPDOWN_WIDTH: f32 = 250.;
impl WarpifyPageView {
fn create_ssh_extension_install_mode_dropdown(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<Dropdown<WarpifyPageAction>> {
let items: Vec<DropdownItem<WarpifyPageAction>> = SshExtensionInstallMode::iter()
.map(|mode| {
DropdownItem::new(
mode.display_name(),
WarpifyPageAction::SetSshExtensionInstallMode(mode),
)
})
.collect();
let current_mode = *WarpifySettings::as_ref(ctx)
.ssh_extension_install_mode
.value();
let enable_ssh_warpification = *WarpifySettings::as_ref(ctx)
.enable_ssh_warpification
.value();
ctx.add_typed_action_view(move |ctx| {
let mut dropdown = Dropdown::new(ctx);
dropdown.set_top_bar_max_width(SSH_EXTENSION_DROPDOWN_WIDTH);
dropdown.set_menu_width(SSH_EXTENSION_DROPDOWN_WIDTH, ctx);
dropdown.add_items(items, ctx);
dropdown.set_selected_by_action(
WarpifyPageAction::SetSshExtensionInstallMode(current_mode),
ctx,
);
if !enable_ssh_warpification {
dropdown.set_disabled(ctx);
}
dropdown
})
}
/// Renders a title, a list of items that can be removed, and an input field to add new items.
fn build_input_list<
ListItem: Display,
SettingsPageAction: Action + Clone,
F: Fn(usize) -> SettingsPageAction,
T: View,
>(
&self,
title: &str,
patterns: &[ListItem],
mouse_states: &[MouseStateHandle],
create_action: F,
handle: &ViewHandle<T>,
appearance: &Appearance,
) -> Container {
let mut column = Flex::column();
let mut title = build_sub_sub_title(title, appearance);
if !patterns.is_empty() {
title = title.with_padding_bottom(BUILT_IN_TEXT_INPUT_MARGIN);
}
column.add_child(title.finish());
render_alternating_color_list(
&mut column,
patterns,
mouse_states,
create_action,
appearance,
);
Container::new(
column
.with_child(
Container::new(ChildView::new(handle).finish())
.with_margin_bottom(SPACE_AFTER_TEXT_INPUT)
.finish(),
)
.finish(),
)
}
}
impl View for WarpifyPageView {
fn ui_name() -> &'static str {
"WarpifyPageView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum WarpifyPageAction {
RemoveAddedCommand(usize),
RemoveDenylistedCommand(usize),
RemoveDenylistedSshHost(usize),
/// If disabled, auto-Warpification and the SSH Warpification prompt will be disabled.
ToggleTmuxWarpification,
ToggleSshWarpification,
/// Set the SSH extension installation mode (always ask / always install / always skip).
SetSshExtensionInstallMode(SshExtensionInstallMode),
OpenUrl(String),
}
impl TypedActionView for WarpifyPageView {
type Action = WarpifyPageAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
use WarpifyPageAction::*;
match action {
RemoveDenylistedCommand(index) => self.remove_denylisted_command(*index, ctx),
RemoveAddedCommand(index) => self.remove_added_command(*index, ctx),
ToggleSshWarpification => {
WarpifySettings::handle(ctx).update(ctx, |ssh_settings, ctx| {
report_if_error!(ssh_settings
.enable_ssh_warpification
.toggle_and_save_value(ctx));
send_telemetry_from_ctx!(
TelemetryEvent::ToggleSshWarpification {
enabled: *ssh_settings.enable_ssh_warpification.value(),
},
ctx
);
});
let enabled = *WarpifySettings::as_ref(ctx)
.enable_ssh_warpification
.value();
self.ssh_extension_install_mode_dropdown
.update(ctx, |dropdown, ctx| {
if enabled {
dropdown.set_enabled(ctx);
} else {
dropdown.set_disabled(ctx);
}
});
}
ToggleTmuxWarpification => {
WarpifySettings::handle(ctx).update(ctx, |ssh_settings, ctx| {
report_if_error!(ssh_settings.use_ssh_tmux_wrapper.toggle_and_save_value(ctx));
send_telemetry_from_ctx!(
TelemetryEvent::ToggleSshTmuxWrapper {
enabled: *ssh_settings.use_ssh_tmux_wrapper.value(),
},
ctx
);
});
}
SetSshExtensionInstallMode(mode) => {
WarpifySettings::handle(ctx).update(ctx, |warpify_settings, ctx| {
report_if_error!(warpify_settings
.ssh_extension_install_mode
.set_value(*mode, ctx));
send_telemetry_from_ctx!(
TelemetryEvent::SetSshExtensionInstallMode {
mode: mode.display_name(),
},
ctx
);
});
}
WarpifyPageAction::RemoveDenylistedSshHost(index) => {
self.remove_denylisted_ssh_host(*index, ctx);
}
OpenUrl(url) => {
ctx.open_url(url.as_str());
}
}
}
}
impl SettingsPageMeta for WarpifyPageView {
fn section() -> SettingsSection {
SettingsSection::Warpify
}
fn should_render(&self, _ctx: &AppContext) -> bool {
true
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
self.page.update_filter(query, ctx)
}
fn scroll_to_widget(&mut self, widget_id: &'static str) {
self.page.scroll_to_widget(widget_id)
}
fn clear_highlighted_widget(&mut self) {
self.page.clear_highlighted_widget();
}
}
impl From<ViewHandle<WarpifyPageView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<WarpifyPageView>) -> Self {
SettingsPageViewHandle::Warpify(view_handle)
}
}
#[derive(Default)]
struct TitleWidget {
learn_more_highlight_index: HighlightedHyperlink,
}
impl TitleWidget {
fn render_top_of_page(&self, appearance: &Appearance, _app: &AppContext) -> Box<dyn Element> {
let warpify_description = vec![
FormattedTextFragment::plain_text(
"Configure whether Warp attempts to “Warpify” (add support for blocks, \
input modes, etc) certain shells. ",
),
FormattedTextFragment::hyperlink(
"Learn more",
"https://docs.warp.dev/terminal/warpify/subshells",
),
];
let warpify_description = FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(warpify_description)]),
CONTENT_FONT_SIZE,
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()),
self.learn_more_highlight_index.clone(),
)
.with_hyperlink_font_color(appearance.theme().accent().into_solid())
.register_default_click_handlers(|url, _, ctx| {
ctx.open_url(&url.url);
})
.finish();
Flex::column()
.with_child(render_page_title("Warpify", HEADER_FONT_SIZE, appearance))
.with_child(warpify_description)
.finish()
}
}
impl SettingsWidget for TitleWidget {
type View = WarpifyPageView;
fn search_terms(&self) -> &str {
"ssh subshell warpify session"
}
fn render(
&self,
_view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
Container::new(self.render_top_of_page(appearance, app))
.with_margin_bottom(ITEM_VERTICAL_SPACING)
.finish()
}
}
#[derive(Default)]
struct SubshellsWidget {}
impl SubshellsWidget {
fn render_subshells_section(
&self,
view: &WarpifyPageView,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let mut column = Flex::column();
let warpify_settings = WarpifySettings::as_ref(app);
column.add_child(
view.build_input_list(
"Added commands",
&warpify_settings.added_subshell_commands,
&view.remove_added_command_button_states,
WarpifyPageAction::RemoveAddedCommand,
&view.add_added_commands_editor,
appearance,
)
.finish(),
);
column.add_child(
view.build_input_list(
"Denylisted commands",
&warpify_settings.subshell_command_denylist,
&view.remove_denylisted_command_button_states,
WarpifyPageAction::RemoveDenylistedCommand,
&view.add_denylisted_commands_editor,
appearance,
)
.with_margin_bottom(-BUILT_IN_TEXT_INPUT_MARGIN)
.finish(),
);
column.finish()
}
}
impl SettingsWidget for SubshellsWidget {
type View = WarpifyPageView;
fn search_terms(&self) -> &str {
"warpify subshell"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
Container::new(self.render_subshells_section(view, appearance, app))
.with_margin_bottom(ITEM_VERTICAL_SPACING)
.finish()
}
}
#[derive(Default)]
struct SSHWidget {
tmux_warpification_switch_state: SwitchStateHandle,
enable_ssh_warpification_switch_state: SwitchStateHandle,
additional_info_mouse_state: MouseStateHandle,
local_only_icon_tooltip_states: RefCell<HashMap<String, MouseStateHandle>>,
}
impl SettingsWidget for SSHWidget {
type View = WarpifyPageView;
fn search_terms(&self) -> &str {
"warpify ssh"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let mut column = Flex::column();
let ui_builder = appearance.ui_builder();
let description_text_color = appearance
.theme()
.sub_text_color(appearance.theme().surface_2());
let enable_ssh_warpification = *WarpifySettings::as_ref(app)
.enable_ssh_warpification
.value();
let should_prompt_ssh_tmux_wrapper =
*WarpifySettings::as_ref(app).use_ssh_tmux_wrapper.value();
add_setting(
&mut column,
&WarpifySettings::as_ref(app).enable_ssh_warpification,
move || {
render_body_item::<WarpifyPageAction>(
"Warpify SSH Sessions".into(),
None,
LocalOnlyIconState::for_setting(
EnableSshWarpification::storage_key(),
EnableSshWarpification::sync_to_cloud(),
&mut self.local_only_icon_tooltip_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
ui_builder
.switch(self.enable_ssh_warpification_switch_state.clone())
.check(enable_ssh_warpification)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WarpifyPageAction::ToggleSshWarpification);
})
.finish(),
None,
)
},
);
if FeatureFlag::SshRemoteServer.is_enabled() {
let label_color_override = if !enable_ssh_warpification {
Some(appearance.theme().disabled_ui_text_color())
} else {
None
};
add_setting(
&mut column,
&WarpifySettings::as_ref(app).ssh_extension_install_mode,
move || {
Container::new(render_dropdown_item(
appearance,
"Install SSH extension",
Some(SSH_EXTENSION_INSTALL_MODE_DESCRIPTION),
None,
LocalOnlyIconState::for_setting(
SshExtensionInstallMode::storage_key(),
SshExtensionInstallMode::sync_to_cloud(),
&mut self.local_only_icon_tooltip_states.borrow_mut(),
app,
),
label_color_override,
&view.ssh_extension_install_mode_dropdown,
))
.with_padding_bottom(HEADER_PADDING)
.finish()
},
);
}
add_setting(
&mut column,
&WarpifySettings::as_ref(app).use_ssh_tmux_wrapper,
move || {
let mut column = Flex::column();
column.add_child(render_body_item::<WarpifyPageAction>(
"Use Tmux Warpification".into(),
Some(AdditionalInfo {
mouse_state: self.additional_info_mouse_state.clone(),
on_click_action: Some(WarpifyPageAction::OpenUrl(
"https://docs.warp.dev/terminal/warpify/ssh".into(),
)),
secondary_text: None,
tooltip_override_text: None,
}),
LocalOnlyIconState::for_setting(
UseSshTmuxWrapper::storage_key(),
UseSshTmuxWrapper::sync_to_cloud(),
&mut self.local_only_icon_tooltip_states.borrow_mut(),
app,
),
enable_ssh_warpification.into(),
appearance,
ui_builder
.switch(self.tmux_warpification_switch_state.clone())
.check(should_prompt_ssh_tmux_wrapper)
.with_disabled(!enable_ssh_warpification)
.build()
.on_click(move |ctx, _, _| {
if !enable_ssh_warpification {
return;
}
ctx.dispatch_typed_action(WarpifyPageAction::ToggleTmuxWarpification);
})
.finish(),
None,
));
column.add_child(
ui_builder
.paragraph(SSH_TMUX_WARPIFICATION_DESCRIPTION.to_owned())
.with_style(UiComponentStyles {
font_color: Some(description_text_color.into_solid()),
margin: Some(
Coords::default()
.top(styles::DESCRIPTION_NEGATIVE_MARGIN_OFFSET)
.bottom(styles::DESCRIPTION_LINE_MARGIN_BOTTOM),
),
..Default::default()
})
.build()
.finish(),
);
if enable_ssh_warpification && should_prompt_ssh_tmux_wrapper {
let warpify_settings = WarpifySettings::as_ref(app);
column.add_child(
view.build_input_list(
"Denylisted hosts",
&warpify_settings.ssh_hosts_denylist,
&view.remove_denylisted_ssh_button_states,
WarpifyPageAction::RemoveDenylistedSshHost,
&view.add_denylisted_ssh_editor,
appearance,
)
.finish(),
);
} else {
// Add margin to hint the user should scroll to see more.
column.add_child(
Container::new(Flex::column().finish())
.with_margin_bottom(styles::MINIMUM_SCROLL_OFFSET_AFTER_SSH)
.finish(),
);
}
column.finish()
},
);
column.finish()
}
}
mod styles {
// Apply a negative margin to the description text so it appears closer to the main
// settings option text.
pub const DESCRIPTION_NEGATIVE_MARGIN_OFFSET: f32 = -8.;
/// The space after a description.
pub const DESCRIPTION_LINE_MARGIN_BOTTOM: f32 = 18.;
/// Because we hide the SSH settings if the SSH wrapper is disabled, we need to add a margin
/// to the bottom to make it clear that toggling this item will reveal more settings,
/// even at smaller window sizes. We picked an offset that cuts off the first item
/// to imply the user should scroll to see more.
pub const MINIMUM_SCROLL_OFFSET_AFTER_SSH: f32 = 40.;
}