Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(§ion)
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.;
|
||||
}
|
||||
Reference in New Issue
Block a user