Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# Environment Variables Documentation
This document provides information about our "Environment Variables" feature. Internally, we refer to these objects as `EnvVarCollection`s (EVCs). Views bound to this object are often referred to by the string above, whereas functions and variables are usually named `env_var_collection`.
This documentation is up-to-date as of 6/26/2024. All referenced files are present in this directory unless specified otherwise.
## Core Data Models
The core data model for EVCs is defined in `mod.rs`. The motivations behind our data model are detailed in the above documents, with the v1 tech doc being the most relevant.
## Cloud Infrastructure
Context: EVCs are built on GenericStringObjects (GSOs). Consequently, there isn't much unique server-side infrastructure dedicated to EVCs — we added a variant to the `Format` enum on the server side and did the same on the client (`JsonObjectType::EnvVarCollection`), and a small DB migration to support the type.
We defined `CloudEnvVarCollection` in `mod.rs`, which implements the `GenericCloudObjectType` trait. This is a mostly boilerplate implementation specifying properties such as EVCs should render in Warp Drive, be linkable/exportable, etc.
The implementation of EVCs as a Warp Drive object is in `app/src/drive/items/env_var_collection.rs`, where code for the Warp Drive preview and click action is located.
Code relevant to edit collisions and fetching EVCs from the server is in `app/src/server/server_api.rs` and `app/src/server/update_manager.rs`. We aimed to maintain a similar liveness property to workflows, meaning a concurrent edit made by another user requires one to check out the other's edit before committing their own.
## Client Side
### Panes
EVCs, like most objects in Warp, are children of a pane. Our implementation is defined in `app/src/pane_group/pane/env_var_collection_pane.rs`, which is essentially identical to other pane implementations. The `EnvVarCollectionPane` is closely coupled with the `EnvVarCollectionManager`, defined in `manager.rs`. The manager is responsible for creating, destroying, and registering all EVC panes, whereas the pane itself contains the EVC view.
### Core UI
We'll describe our core UI components by line-by-lining each file in the view directory, ordered by importance.
- `env_var_collection.rs` — Contains the core functions and implementation of the `EnvVarCollectionView`. Functions like "open_new_env_var_collection" and "load" (which loads an existing EVC or reloads an open EVC after a collision) are documented with descriptions of their relevance.
- `secrets.rs` — separate section below as it's a crucial flow
- `command_dialog`
- `view.rs` — Defines the view for the command dialog.
- `mod.rs` — Contains functionality related to the command dialog i.e. (listening to events from the dialog)
- `unsaved_changes_dialog.rs` — Contains code related to the dialog presented when a user tries to close the pane without saving changes.
- `menus.rs` — Defines menu-related code for EVCs. This includes secret menus (linked to the key icon or a rendered secret/command) and pane-bound menus (overflow menu with object-specific actions and the context menu with split pane actions, triggered on right-click).
- `editors.rs` — Defines code for initializing editors, handling their events (such as tab navigation), and rendering the "metadata" section.
- `section_headers_and_footers.rs` — Contains render functions for components like the trash overflow banner or the save button in the footer.
- `active_env_var_collection_data.rs` — Tracks the currently open EVC, including the current revision and saving status.
### Secrets
Secret initialization can be best described by examining the full flow:
1. The user clicks on a menu linked to a row (key icon or rendered secret/command), dispatching a `DisplaySecretMenu(VariableRowIndex)` action.
2. The action is handled, storing the `VariableRowIndex` in the `pending_variable_row_index` state variable.
3. The user selects a menu item (e.g., 1password), triggering a `SelectSecretManager` action, which resolves to the `fetch_secret` function.
4. In `fetch_secret`, the following occurs:
1. Data about the user's local shell is retrieved to run the command which fetches all the user's secrets
2. On a background thread, the `verify_installed_and_fetch_secrets` function in `app/src/external_secrets/mod.rs` is executed. This function checks if the selected secret manager is installed and tries to fetch secrets using the aforementioned local_shell module (well documented). If either operation fails, `fetch_secret` displays an error toast.
5. Assuming secrets are successfully fetched, they are sent to the searchable secrets dialog (located in `app/src/search/external_secrets`), which propagates an event back to the EVC view to indicate the dialog should be opened.
6. The user selects a secret, propagating an event to the EVC view, which stores the secret in the value field of the `VariableEditorRow` pointed to by `pending_variable_row_index` and closes the dialog.
### Other
- Code for the EVC portion of the workflow card (parameterized workflows) is defined in `app/src/workflows/info_box.rs`.
- Code related to command palette and search functionality is in their respective directories located in `app/src/search`.
- Code for the EVC block appended to the blocklist prior to invocation is in `env_var_collection_block.rs`. Commands that set/initialize variables are established in `mod.rs`. The codepath for invoking an EVC is in `invoke_environment_variables` of `app/src/terminal/view.rs`.
@@ -0,0 +1,313 @@
use crate::{
cloud_object::{
breadcrumbs::ContainingObject,
model::{persistence::CloudModelEvent, view::CloudViewModel},
CloudObject, Owner, Revision, Space,
},
drive::sharing::{ContentEditability, SharingAccessLevel},
env_vars::CloudEnvVarCollection,
server::{
cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManagerEvent,
},
ids::{ClientId, ServerId, SyncId},
},
AppContext, CloudModel, UpdateManager,
};
use warpui::{Entity, ModelContext, SingletonEntity};
use super::CloudEnvVarCollectionModel;
#[derive(Default, Clone)]
pub enum ActiveEnvVarCollection {
#[default]
None,
// An EnvVarCollection already stored in CloudModel, all relevant data should be queried
// from CloudModel directly
CommittedEnvVarCollection(SyncId),
// An EnvVarCollection that has been created and displayed in the view, but is not yet
// committed to CloudModel
NewEnvVarCollection(Box<CloudEnvVarCollection>),
}
#[derive(Default, PartialEq, Debug)]
pub enum SavingStatus {
#[default]
Saved,
Unsaved,
New,
}
#[derive(Default)]
pub struct ActiveEnvVarCollectionData {
pub saving_status: SavingStatus,
pub active_env_var_collection: ActiveEnvVarCollection,
pub revision_ts: Option<Revision>,
}
impl ActiveEnvVarCollectionData {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let update_manager = UpdateManager::handle(ctx);
ctx.subscribe_to_model(&update_manager, |me, event, ctx| {
me.handle_update_manager_event(event, ctx);
});
let cloud_model = CloudModel::handle(ctx);
ctx.subscribe_to_model(&cloud_model, |me, event, ctx| {
me.handle_cloud_model_event(event, ctx);
});
Self {
..Default::default()
}
}
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
if let CloudModelEvent::ObjectMoved { type_and_id, .. } = event {
if let Some(env_var_collection_id) = type_and_id.as_generic_string_object_id() {
if self.is_active_env_var_collection(env_var_collection_id) {
ctx.emit(ActiveEnvVarCollectionDataEvent::BreadcrumbsChanged)
}
}
}
}
fn handle_update_manager_event(
&mut self,
event: &UpdateManagerEvent,
ctx: &mut ModelContext<Self>,
) {
let cloud_model = CloudModel::as_ref(ctx);
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
return;
};
match (&result.operation, &result.success_type) {
(ObjectOperation::Create { .. }, OperationSuccessType::Success) => {
if let Some(current_id) = self.id() {
if current_id.into_client() == result.client_id {
let server_id = result.server_id.expect("Expect server id on success");
let env_var_collection_id = SyncId::ServerId(server_id);
if let Some(env_var_collection) =
cloud_model.get_env_var_collection(&env_var_collection_id)
{
self.saving_status = SavingStatus::Saved;
self.active_env_var_collection =
ActiveEnvVarCollection::CommittedEnvVarCollection(
env_var_collection_id,
);
self.revision_ts
.clone_from(&env_var_collection.metadata.revision);
ctx.emit(ActiveEnvVarCollectionDataEvent::CreatedOnServer(server_id));
ctx.notify();
}
}
}
}
(ObjectOperation::Update, OperationSuccessType::Success) => {
if let Some(current_id) = self.id() {
// If we match on a non-None client id or a non-None server id then
// update the data
if (current_id.into_client().is_some()
&& current_id.into_client() == result.client_id)
|| (current_id.into_server().is_some()
&& current_id.into_server() == result.server_id)
{
let server_id = result.server_id.expect("Expect server id on success");
let env_var_collection_id = SyncId::ServerId(server_id);
if let Some(env_var_collection) =
cloud_model.get_env_var_collection(&env_var_collection_id)
{
self.saving_status = SavingStatus::Saved;
self.active_env_var_collection =
ActiveEnvVarCollection::CommittedEnvVarCollection(
env_var_collection_id,
);
self.revision_ts
.clone_from(&env_var_collection.metadata.revision);
ctx.notify();
}
}
}
}
(ObjectOperation::Trash, OperationSuccessType::Success)
| (ObjectOperation::Untrash, OperationSuccessType::Success) => {
let server_id = result.server_id.expect("Expect server id on success");
if let Some(current_id) = self.id() {
if current_id.into_client() == result.client_id
&& cloud_model
.get_env_var_collection(&SyncId::ServerId(server_id))
.is_some()
{
ctx.emit(ActiveEnvVarCollectionDataEvent::TrashStatusChanged);
}
}
}
_ => {}
}
}
pub fn reset(&mut self) {
self.active_env_var_collection = ActiveEnvVarCollection::None;
}
pub fn open_new(
&mut self,
owner: Owner,
initial_folder_id: Option<SyncId>,
ctx: &mut ModelContext<Self>,
) {
self.reset();
let new_id = ClientId::default();
// Set the active env var collection to be an uncommited collection
self.active_env_var_collection = ActiveEnvVarCollection::NewEnvVarCollection(Box::new(
CloudEnvVarCollection::new_local(
CloudEnvVarCollectionModel::default(),
owner,
initial_folder_id,
new_id,
),
));
ctx.emit(ActiveEnvVarCollectionDataEvent::BreadcrumbsChanged);
ctx.notify();
}
pub fn open_existing(&mut self, env_var_collection_id: SyncId, ctx: &mut ModelContext<Self>) {
self.reset();
self.saving_status = SavingStatus::Saved;
self.active_env_var_collection =
ActiveEnvVarCollection::CommittedEnvVarCollection(env_var_collection_id);
ctx.emit(ActiveEnvVarCollectionDataEvent::BreadcrumbsChanged);
ctx.notify();
}
pub fn id(&self) -> Option<SyncId> {
match &self.active_env_var_collection {
ActiveEnvVarCollection::None => None,
ActiveEnvVarCollection::CommittedEnvVarCollection(id) => Some(*id),
ActiveEnvVarCollection::NewEnvVarCollection(env_var_collection) => {
Some(env_var_collection.id)
}
}
}
/// The current user's access level on this env var collection.
pub fn access_level(&self, app: &AppContext) -> SharingAccessLevel {
match &self.active_env_var_collection {
ActiveEnvVarCollection::CommittedEnvVarCollection(sync_id) => {
CloudViewModel::as_ref(app).access_level(&sync_id.uid(), app)
}
ActiveEnvVarCollection::None | ActiveEnvVarCollection::NewEnvVarCollection(_) => {
SharingAccessLevel::Full
}
}
}
pub fn editability(&self, app: &AppContext) -> ContentEditability {
match &self.active_env_var_collection {
ActiveEnvVarCollection::CommittedEnvVarCollection(sync_id) => {
CloudViewModel::as_ref(app).object_editability(&sync_id.uid(), app)
}
ActiveEnvVarCollection::None | ActiveEnvVarCollection::NewEnvVarCollection(_) => {
ContentEditability::Editable
}
}
}
/// The space that this env var collection is in.
pub fn space(&self, app: &AppContext) -> Option<Space> {
match &self.active_env_var_collection {
ActiveEnvVarCollection::None => None,
ActiveEnvVarCollection::CommittedEnvVarCollection(sync_id) => {
CloudViewModel::as_ref(app).object_space(&sync_id.uid(), app)
}
ActiveEnvVarCollection::NewEnvVarCollection(env_var_collection) => {
Some(env_var_collection.space(app))
}
}
}
pub fn active_env_var_collection(&self) -> ActiveEnvVarCollection {
self.active_env_var_collection.clone()
}
/// Whether or not the EVC has been synced to the server.
pub fn is_on_server(&self) -> bool {
matches!(
&self.active_env_var_collection,
ActiveEnvVarCollection::CommittedEnvVarCollection(SyncId::ServerId(_))
)
}
pub fn is_active_env_var_collection(&self, env_var_collection_id: SyncId) -> bool {
self.id() == Some(env_var_collection_id)
}
pub fn breadcrumbs(&self, ctx: &AppContext) -> Option<Vec<ContainingObject>> {
let cloud_env_var_collection = match &self.active_env_var_collection {
ActiveEnvVarCollection::None => None,
ActiveEnvVarCollection::CommittedEnvVarCollection(id) => {
CloudModel::as_ref(ctx).get_env_var_collection(id)
}
ActiveEnvVarCollection::NewEnvVarCollection(env_var_collection) => {
Some(env_var_collection.as_ref())
}
};
cloud_env_var_collection
.map(|env_var_collection| env_var_collection.containing_objects_path(ctx))
}
pub fn trash_status(&self, ctx: &AppContext) -> TrashStatus {
match &self.active_env_var_collection {
ActiveEnvVarCollection::None | ActiveEnvVarCollection::NewEnvVarCollection(_) => {
TrashStatus::Active
}
ActiveEnvVarCollection::CommittedEnvVarCollection(id) => {
let cloud_model = CloudModel::as_ref(ctx);
match cloud_model.get_env_var_collection(id) {
Some(env_var_collection) => {
if env_var_collection.is_trashed(cloud_model) {
TrashStatus::Trashed
} else {
TrashStatus::Active
}
}
None => TrashStatus::Deleted,
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TrashStatus {
Active,
Trashed,
Deleted,
}
pub enum ActiveEnvVarCollectionDataEvent {
/// The EVC's breadcrumbs were updated.
BreadcrumbsChanged,
/// The EVC was synced to the server for the first time.
CreatedOnServer(ServerId),
/// The EVC was trashed or untrashed
/// (used for refreshing the pane overflow items)
TrashStatusChanged,
}
impl Entity for ActiveEnvVarCollectionData {
type Event = ActiveEnvVarCollectionDataEvent;
}
@@ -0,0 +1,460 @@
use crate::{
ai::agent::icons::{yellow_running_icon, yellow_stop_icon},
view_components::compactible_action_button::{
CompactibleActionButton, RenderCompactibleActionButton, SMALL_SIZE_SWITCH_THRESHOLD,
},
};
use lazy_static::lazy_static;
use parking_lot::RwLock;
use settings::Setting as _;
use std::borrow::Cow;
use std::rc::Rc;
use std::sync::Arc;
use warp_core::semantic_selection::SemanticSelection;
use warp_core::{features::FeatureFlag, ui::Icon};
use warpui::{
elements::{
get_rich_content_position_id, Border, Clipped, Container, CornerRadius, CrossAxisAlignment,
Flex, FormattedTextElement, MouseStateHandle, ParentElement, Radius, SavePosition,
SelectableArea, SelectionHandle,
},
keymap::{FixedBinding, Keystroke},
AppContext, Element, Entity, EntityId, FocusContext, SingletonEntity, TypedActionView, View,
ViewContext,
};
use crate::{
ai::blocklist::block::view_impl::{CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN},
ai::blocklist::inline_action::inline_action_header::INLINE_ACTION_HORIZONTAL_PADDING,
ai::blocklist::inline_action::inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode,
},
ai::blocklist::inline_action::inline_action_icons::{self},
appearance::Appearance,
settings::InputModeSettings,
terminal::{
block_list_element::BlockListMenuSource, block_list_viewport::InputMode,
view::TerminalAction,
},
ui_components::blended_colors,
view_components::action_button::{ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme},
};
/// The vertical padding applied to the env var collection block's content body.
/// For horizontal padding, use [`INLINE_ACTION_HORIZONTAL_PADDING`] for consistency.
const ENV_VAR_COLLECTION_BODY_VERTICAL_PADDING: f32 = 16.;
const ENV_VAR_COLLECTION_CANCEL_LABEL: &str = "Cancel";
const ENV_VAR_COLLECTION_ACCEPT_LABEL: &str = "Run";
lazy_static! {
static ref CANCEL_ENV_VAR_COLLECTION_KEYSTROKE: Keystroke = Keystroke {
ctrl: true,
key: "c".to_owned(),
..Default::default()
};
static ref ACCEPT_ENV_VAR_COLLECTION_KEYSTROKE: Keystroke = Keystroke {
key: "enter".to_owned(),
..Default::default()
};
}
#[derive(Debug, Clone)]
pub enum EnvVarCollectionBlockEvent {
Cancelled,
RanCommand(String),
ToggledExpanded(String),
TextSelected,
}
#[derive(Debug, Clone)]
pub enum EnvVarCollectionBlockAction {
Cancel,
RunCommand,
/// Only applies to text selections made at the `EnvVarCollectionBlock` level. Child views of the
/// `EnvVarCollectionBlock` are responsible for managing their own text selection states.
SelectText,
ToggleExpanded,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum EnvVarCollectionState {
/// The env var command is loaded and waiting to be run by the user.
WaitingForUser,
/// The env var command is currently running, after being accepted by the user.
Running,
/// The env var command finished running and succeeded.
Succeeded,
/// The env var command finished running and failed.
Failed,
/// The env var command was cancelled at some point before completing
/// (i.e. before [`Self::Succeeded`] or [`Self::Failed`]).
Cancelled,
}
impl EnvVarCollectionState {
fn has_completed(&self) -> bool {
matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
}
}
pub struct EnvVarCollectionBlock {
cancel_button: CompactibleActionButton,
accept_button: CompactibleActionButton,
command: String,
command_output: Option<String>,
state: EnvVarCollectionState,
block_id: String,
view_id: EntityId,
/// The output grid needs to be selectable to allow users to copy the command to their clipboard.
/// Only applies to text selections made at the `EnvVarCollectionBlock` level. Child views of the
/// `EnvVarCollectionBlock` are responsible for managing their own text selection states.
selection_handle: SelectionHandle,
selected_text: Arc<RwLock<Option<String>>>,
header_is_expanded: bool,
header_mouse_state: MouseStateHandle,
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([
FixedBinding::new(
"ctrl-c",
EnvVarCollectionBlockAction::Cancel,
id!(EnvVarCollectionBlock::ui_name()),
),
FixedBinding::new(
"enter",
EnvVarCollectionBlockAction::RunCommand,
id!(EnvVarCollectionBlock::ui_name()),
),
FixedBinding::new(
"numpadenter",
EnvVarCollectionBlockAction::RunCommand,
id!(EnvVarCollectionBlock::ui_name()),
),
]);
}
impl EnvVarCollectionBlock {
pub fn new(
block_id: String,
_collection_title: String,
command: String,
ctx: &mut ViewContext<Self>,
) -> Self {
let cancel_button = CompactibleActionButton::new(
ENV_VAR_COLLECTION_CANCEL_LABEL.to_string(),
Some(KeystrokeSource::Fixed(
CANCEL_ENV_VAR_COLLECTION_KEYSTROKE.clone(),
)),
ButtonSize::InlineActionHeader,
EnvVarCollectionBlockAction::Cancel,
Icon::X,
Arc::new(NakedTheme),
ctx,
);
let accept_button = CompactibleActionButton::new(
ENV_VAR_COLLECTION_ACCEPT_LABEL.to_string(),
Some(KeystrokeSource::Fixed(
ACCEPT_ENV_VAR_COLLECTION_KEYSTROKE.clone(),
)),
ButtonSize::InlineActionHeader,
EnvVarCollectionBlockAction::RunCommand,
Icon::Check,
Arc::new(PrimaryTheme),
ctx,
);
Self {
cancel_button,
accept_button,
command,
command_output: None,
state: EnvVarCollectionState::WaitingForUser,
block_id,
view_id: ctx.view_id(),
selection_handle: Default::default(),
selected_text: Default::default(),
header_is_expanded: false,
header_mouse_state: Default::default(),
}
}
fn handle_toggle_expanded(&mut self, ctx: &mut ViewContext<Self>) {
self.header_is_expanded = !self.header_is_expanded;
ctx.emit(EnvVarCollectionBlockEvent::ToggledExpanded(
self.block_id.clone(),
));
ctx.notify();
}
pub fn focus(&self, ctx: &mut ViewContext<Self>) {
ctx.focus_self();
}
pub fn get_block_id(&self) -> &String {
&self.block_id
}
pub fn is_block_completed(&self) -> bool {
self.state.has_completed()
}
pub fn is_running(&self) -> bool {
self.state == EnvVarCollectionState::Running
}
pub fn on_succeeded(&mut self, ctx: &mut ViewContext<Self>) {
self.state = EnvVarCollectionState::Succeeded;
ctx.notify();
}
pub fn on_failed(&mut self, output: Option<String>, ctx: &mut ViewContext<Self>) {
self.command_output = output;
self.state = EnvVarCollectionState::Failed;
ctx.notify();
}
fn run_command(&mut self, ctx: &mut ViewContext<Self>) {
if !self.is_block_completed() {
self.state = EnvVarCollectionState::Running;
ctx.emit(EnvVarCollectionBlockEvent::RanCommand(self.command.clone()));
ctx.notify();
}
}
pub fn cancel(&mut self, ctx: &mut ViewContext<Self>) {
if !self.is_block_completed() {
self.state = EnvVarCollectionState::Cancelled;
ctx.emit(EnvVarCollectionBlockEvent::Cancelled);
ctx.notify();
}
}
/// Returns the currently selected text within the entire `EnvVarCollectionBlock` view sub-hierarchy.
/// There **shouldn't** be more than one instance of selected text at any given time across
/// any view within the same `EnvVarCollectionBlock` view sub-hierarchy.
pub fn selected_text(&self, _ctx: &AppContext) -> Option<String> {
self.selected_text.read().clone()
}
pub fn clear_selection(&mut self, ctx: &mut ViewContext<Self>) {
self.selection_handle.clear();
*self.selected_text.write() = None;
ctx.notify();
}
pub fn handle_ctrl_c(&mut self, ctx: &mut ViewContext<Self>) {
self.cancel(ctx);
}
fn render_header(&self, app: &AppContext) -> Box<dyn Element> {
const COMMAND_WAITING_FOR_USER_MESSAGE: &str =
"OK if I run this command and read the output?";
let title: Cow<'static, str> = if self.state == EnvVarCollectionState::WaitingForUser {
COMMAND_WAITING_FOR_USER_MESSAGE.into()
} else {
self.command.clone().into()
};
let appearance = Appearance::as_ref(app);
let icon = match self.state {
EnvVarCollectionState::WaitingForUser => Some(yellow_stop_icon(appearance)),
EnvVarCollectionState::Running => Some(yellow_running_icon(appearance)),
EnvVarCollectionState::Succeeded => {
Some(inline_action_icons::green_check_icon(appearance))
}
EnvVarCollectionState::Failed => Some(inline_action_icons::red_x_icon(appearance)),
EnvVarCollectionState::Cancelled => {
Some(inline_action_icons::cancelled_icon(appearance))
}
};
let interaction_mode = match self.state {
EnvVarCollectionState::WaitingForUser => {
let buttons: Vec<Rc<dyn RenderCompactibleActionButton>> = vec![
Rc::new(self.cancel_button.clone()),
Rc::new(self.accept_button.clone()),
];
Some(InteractionMode::ActionButtons {
action_buttons: buttons,
size_switch_threshold: SMALL_SIZE_SWITCH_THRESHOLD,
})
}
EnvVarCollectionState::Failed => {
let expansion_config =
ExpandedConfig::new(self.header_is_expanded, self.header_mouse_state.clone())
.with_toggle_callback(move |ctx| {
ctx.dispatch_typed_action(EnvVarCollectionBlockAction::ToggleExpanded);
});
Some(InteractionMode::ManuallyExpandable(expansion_config))
}
_ => None,
};
let mut config = HeaderConfig::new(title, app).with_selectable_text();
if let Some(icon) = icon {
config = config.with_icon(icon);
}
if let Some(mode) = interaction_mode {
config = config.with_interaction_mode(mode);
}
config.render(app)
}
}
impl Entity for EnvVarCollectionBlock {
type Event = EnvVarCollectionBlockEvent;
}
impl View for EnvVarCollectionBlock {
fn ui_name() -> &'static str {
"EnvVarCollectionBlock"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
// Build the stateless header based on current state
let header_element = self.render_header(app);
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Clipped::new(header_element).finish());
let is_header_expanded =
self.state == EnvVarCollectionState::WaitingForUser || self.header_is_expanded;
let is_input_pinned_to_top =
*InputModeSettings::as_ref(app).input_mode.value() == InputMode::PinnedToTop;
// If we're expanding the env var collection block downward, we want the "Viewing command
// detail" row to look connected to the block rendered below, which affects styling. Note
// that `EnvVarCollectionState::Failed` is the only state that permits expansion toggling.
let should_expand_downward = is_header_expanded
&& !is_input_pinned_to_top
&& self.state == EnvVarCollectionState::Failed;
if is_header_expanded && self.state == EnvVarCollectionState::WaitingForUser {
let selectable_child = Container::new(
FormattedTextElement::from_str(
self.command.clone(),
appearance.monospace_font_family(),
appearance.monospace_font_size(),
)
.with_color(blended_colors::text_main(theme, theme.background()))
.with_line_height_ratio(1.3)
.set_selectable(true)
.finish(),
)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(ENV_VAR_COLLECTION_BODY_VERTICAL_PADDING)
.with_background(theme.background())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish();
let semantic_selection = SemanticSelection::as_ref(app);
let selected_text = self.selected_text.clone();
let view_id = self.view_id;
let mut selectable = SelectableArea::new(
self.selection_handle.clone(),
move |selection_args, _, _| {
*selected_text.write() = selection_args.selection;
},
SavePosition::new(
selectable_child,
get_rich_content_position_id(&view_id).as_str(),
)
.finish(),
)
.with_word_boundaries_policy(semantic_selection.word_boundary_policy())
.with_smart_select_fn(semantic_selection.smart_select_fn())
.on_selection_updated(|ctx, _| {
ctx.dispatch_typed_action(EnvVarCollectionBlockAction::SelectText)
})
.on_selection_right_click(move |ctx, position| {
ctx.dispatch_typed_action(TerminalAction::BlockListContextMenu(
BlockListMenuSource::RichContentTextRightClick {
rich_content_view_id: view_id,
position_in_rich_content: position,
},
))
});
if FeatureFlag::RectSelection.is_enabled() {
selectable = selectable.should_support_rect_select();
}
content.add_child(selectable.finish());
}
let border_color = if self.state == EnvVarCollectionState::WaitingForUser {
theme.accent()
} else {
theme.surface_2()
};
let content = Container::new(content.finish())
// Since expanded details are rendered using a regular block, having a non-zero horizontal
// margin while toggled expanded will cause the body to look wider than the header.
.with_horizontal_margin(if should_expand_downward {
0.
} else {
CONTENT_HORIZONTAL_PADDING
})
// Since expanded details are rendered using a regular block, having a non-zero bottom
// margin while toggled expanded will cause the body to look disconnected from the header.
.with_margin_bottom(if should_expand_downward {
0.
} else {
CONTENT_ITEM_VERTICAL_MARGIN
})
// Rounded corners will make the header feel disconnected from its expanded details.
.with_corner_radius(if should_expand_downward {
CornerRadius::with_top(Radius::Pixels(9.))
} else {
CornerRadius::with_all(Radius::Pixels(9.))
})
.with_border(Border::all(1.).with_border_fill(border_color))
.finish();
Container::new(content)
.with_padding_top(CONTENT_ITEM_VERTICAL_MARGIN)
.with_background(theme.ai_blocks_overlay())
.with_border(Border::top(1.).with_border_fill(theme.outline()))
.finish()
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus_self();
ctx.notify();
}
}
}
impl TypedActionView for EnvVarCollectionBlock {
type Action = EnvVarCollectionBlockAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
EnvVarCollectionBlockAction::RunCommand => self.run_command(ctx),
EnvVarCollectionBlockAction::Cancel => self.cancel(ctx),
EnvVarCollectionBlockAction::SelectText => {
ctx.emit(EnvVarCollectionBlockEvent::TextSelected)
}
EnvVarCollectionBlockAction::ToggleExpanded => self.handle_toggle_expanded(ctx),
}
}
}
+224
View File
@@ -0,0 +1,224 @@
use crate::{
cloud_object::{model::persistence::CloudModel, Owner},
env_vars::view::env_var_collection::EnvVarCollectionView,
pane_group::{EnvVarCollectionPane, PaneContent},
safe_warn,
server::{
cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
},
ids::SyncId,
},
PaneViewLocator, WindowId,
};
use std::collections::{hash_map::Entry, HashMap};
use warpui::{Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle};
pub struct EnvVarCollectionManager {
panes_by_hashed_id: HashMap<String, EnvVarCollectionPaneData>,
}
#[derive(Debug, Clone)]
pub enum EnvVarCollectionSource {
Existing(SyncId),
New {
title: Option<String>,
owner: Owner,
initial_folder_id: Option<SyncId>,
},
}
/// Manages EnvVarCollection panes
impl EnvVarCollectionManager {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
ctx.subscribe_to_model(
&UpdateManager::handle(ctx),
Self::handle_update_manager_event,
);
EnvVarCollectionManager {
panes_by_hashed_id: HashMap::new(),
}
}
/// If the collection is already open in a pane, finds the location of that pane.
pub fn find_pane(
&self,
source: &EnvVarCollectionSource,
) -> Option<(WindowId, PaneViewLocator)> {
match source {
EnvVarCollectionSource::Existing(env_var_collection_id) => {
let pane_data = self.panes_by_hashed_id.get(&env_var_collection_id.uid())?;
Some((pane_data.window_id, pane_data.locator))
}
EnvVarCollectionSource::New { .. } => None,
}
}
pub fn create_pane(
&mut self,
source: &EnvVarCollectionSource,
window_id: WindowId,
ctx: &mut ModelContext<Self>,
) -> EnvVarCollectionPane {
let view = ctx.add_typed_action_view(window_id, EnvVarCollectionView::new);
match source {
EnvVarCollectionSource::Existing(env_var_collection_id) => {
let env_var_collection = CloudModel::as_ref(ctx)
.get_env_var_collection(env_var_collection_id)
.cloned();
if let Some(env_var_collection) = env_var_collection {
view.update(ctx, |view, ctx| view.load(env_var_collection, ctx));
} else {
view.update(ctx, |view, ctx| {
view.wait_for_initial_load_then_load(
*env_var_collection_id,
window_id,
ctx,
);
});
}
}
EnvVarCollectionSource::New {
title: _,
owner,
initial_folder_id,
} => view.update(ctx, |view, ctx| {
view.open_new_env_var_collection(*owner, *initial_folder_id, ctx)
}),
}
EnvVarCollectionPane::new(view, ctx)
}
pub fn register_pane(
&mut self,
pane: &EnvVarCollectionPane,
pane_group_id: EntityId,
window_id: WindowId,
ctx: &mut ModelContext<Self>,
) {
let Some(env_var_collection_id) = pane
.env_var_collection_view(ctx)
.as_ref(ctx)
.env_var_collection_id(ctx)
else {
log::warn!("EnvVarCollection pane has no ID");
return;
};
let entry = self.panes_by_hashed_id.entry(env_var_collection_id.uid());
if let Entry::Vacant(entry) = entry {
entry.insert(EnvVarCollectionPaneData {
env_var_collection_id,
window_id,
locator: PaneViewLocator {
pane_group_id,
pane_id: pane.id(),
},
handle: pane.env_var_collection_view(ctx).downgrade(),
});
} else {
safe_warn!(
safe: ("Ignoring duplicate EnvVarCollection pane registration"),
full: ("Ignoring duplicate EnvVarCollection pane registration for {env_var_collection_id}")
);
}
}
pub fn deregister_pane(&mut self, pane: &EnvVarCollectionPane, ctx: &mut ModelContext<Self>) {
let Some(env_var_collection_id) = pane
.env_var_collection_view(ctx)
.as_ref(ctx)
.env_var_collection_id(ctx)
else {
log::warn!("EnvVarCollection pane has no ID");
return;
};
// If an EVC pane is restored, the EVC may have been reopened in the meantime. In
// that case, don't let closing the original pane clear out the new pane.
if let Entry::Occupied(entry) = self.panes_by_hashed_id.entry(env_var_collection_id.uid()) {
if entry.get().locator.pane_id == pane.id() {
entry.remove();
} else {
log::warn!(
"Ignoring duplicate registration of panes for {}",
env_var_collection_id.uid()
);
}
}
}
pub fn reload_collection(
&mut self,
source: &EnvVarCollectionSource,
ctx: &mut ModelContext<Self>,
) {
match source {
EnvVarCollectionSource::Existing(env_var_collection_id) => {
if let Some(pane_data) = self.panes_by_hashed_id.get(&env_var_collection_id.uid()) {
let env_var_collection = CloudModel::as_ref(ctx)
.get_env_var_collection(env_var_collection_id)
.cloned();
if let Some(env_var_collection) = env_var_collection {
if let Some(data) = pane_data.handle.upgrade(ctx) {
data.update(ctx, |view, ctx| view.load(env_var_collection, ctx));
}
}
}
}
_ => log::warn!("Can only reload existing environment variable collection"),
}
}
fn handle_update_manager_event(
&mut self,
event: &UpdateManagerEvent,
ctx: &mut ModelContext<Self>,
) {
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
return;
};
if !matches!(&result.success_type, OperationSuccessType::Success) {
return;
}
if let ObjectOperation::Create { .. } = result.operation {
let server_id = result.server_id.expect("Expect server id on success");
let Some(server_id) = CloudModel::as_ref(ctx)
.get_env_var_collection_by_uid(&server_id.uid())
.and_then(|collection| collection.id.into_server())
else {
return;
};
let Some(client_id) = result.client_id else {
return;
};
if let Some(mut pane) = self.panes_by_hashed_id.remove(&client_id.to_string()) {
pane.env_var_collection_id = SyncId::ServerId(server_id);
self.panes_by_hashed_id
.insert(server_id.uid().clone(), pane);
}
}
}
pub fn reset(&mut self) {
self.panes_by_hashed_id.clear();
}
}
struct EnvVarCollectionPaneData {
env_var_collection_id: SyncId,
window_id: WindowId,
handle: WeakViewHandle<EnvVarCollectionView>,
locator: PaneViewLocator,
}
impl Entity for EnvVarCollectionManager {
type Event = ();
}
impl SingletonEntity for EnvVarCollectionManager {}
+294
View File
@@ -0,0 +1,294 @@
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use view::command_dialog::EnvVarSecretCommand;
use warp_util::path::ShellFamily;
pub mod active_env_var_collection_data;
pub mod env_var_collection_block;
pub mod manager;
pub mod view;
use crate::{
cloud_object::{
model::{
generic_string_model::{GenericStringModel, GenericStringObjectId, StringModel},
json_model::{JsonModel, JsonSerializer},
},
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
JsonObjectType, Revision, ServerCloudObject,
},
drive::items::{env_var_collection::WarpDriveEnvVarCollection, WarpDriveItem},
external_secrets::ExternalSecret,
server::{ids::SyncId, sync_queue::QueueItem},
terminal::shell::ShellType,
Appearance, CloudObjectTypeAndId,
};
#[derive(Clone, Debug, PartialEq)]
pub enum EnvVarCollectionType {
/// Saved env vars, saved using cloud-sync. Today, we only support cloud
Cloud(Box<CloudEnvVarCollection>),
}
impl EnvVarCollectionType {
pub fn as_cloud_env_var_collection(&self) -> &CloudEnvVarCollection {
match self {
EnvVarCollectionType::Cloud(cloud_env_var) => cloud_env_var,
}
}
}
pub type CloudEnvVarCollection =
GenericCloudObject<GenericStringObjectId, CloudEnvVarCollectionModel>;
pub type CloudEnvVarCollectionModel = GenericStringModel<EnvVarCollection, JsonSerializer>;
/// Defines the data model for a single environment variable
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
pub struct EnvVar {
// Variable name
pub name: String,
// Variable value
pub value: EnvVarValue,
// Description of variable
pub description: Option<String>,
}
/// Defines the various forms a value can take
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum EnvVarValue {
// Represents a string variable, i.e. PORT=4000
Constant(String),
// Represents a computed secret, i.e. gcloud print auth token
Command(EnvVarSecretCommand),
// Represents a secret from an external secret manager
Secret(ExternalSecret),
}
impl Default for EnvVarValue {
fn default() -> Self {
EnvVarValue::Constant(String::new())
}
}
impl EnvVar {
pub fn new(name: String, value: String, description: Option<String>) -> Self {
Self {
name,
value: EnvVarValue::Constant(value),
description,
}
}
pub fn get_initialization_string(&self, shell_type: ShellType) -> String {
let shell_family = ShellFamily::from(shell_type);
let name = shell_family.escape(&self.name);
let value = get_init_command_for_env_var(&self.value, shell_family);
match shell_type {
ShellType::Bash | ShellType::Zsh => {
format!("export {name}={value};")
}
ShellType::Fish => {
format!("set -x {name} {value};")
}
ShellType::PowerShell => {
format!("$env:{name} = {value};")
}
}
}
}
fn get_init_command_for_env_var(value: &EnvVarValue, shell_family: ShellFamily) -> String {
match value {
EnvVarValue::Constant(val) => match shell_family {
ShellFamily::Posix => shell_family.escape(val).into_owned(),
ShellFamily::PowerShell => format!("'{}'", val.replace("'", "''")),
},
EnvVarValue::Command(cmd) => format!("$({})", cmd.command),
EnvVarValue::Secret(secret) => {
format!("$({})", secret.get_secret_extraction_command(shell_family))
}
}
}
/// Defines the data model for a cloud synced collection of environment variables.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
pub struct EnvVarCollection {
// Collection title
pub title: Option<String>,
// Description of collection
pub description: Option<String>,
// Environment variables associated with this collection
pub vars: Vec<EnvVar>,
}
impl EnvVarCollection {
#[allow(dead_code)]
pub fn new(title: Option<String>, description: Option<String>, vars: Vec<EnvVar>) -> Self {
Self {
title,
description,
vars,
}
}
fn key_value_iter(&self) -> impl Iterator<Item = (&str, &EnvVarValue)> {
self.vars.iter().map(|var| (var.name.as_str(), &var.value))
}
pub fn export_variables(&self, delimeter: &str, shell_family: ShellFamily) -> String {
serialize_variables_internal(self.key_value_iter(), "", "=", "", delimeter, shell_family)
}
pub fn export_variables_for_shell(&self, shell_type: ShellType) -> String {
serialize_variables_for_shell(self.key_value_iter(), shell_type)
}
}
impl StringModel for EnvVarCollection {
type CloudObjectType = CloudEnvVarCollection;
fn model_type_name(&self) -> &'static str {
"Environment variables"
}
fn should_enforce_revisions() -> bool {
true
}
fn model_format() -> GenericStringObjectFormat {
GenericStringObjectFormat::Json(Self::json_object_type())
}
fn display_name(&self) -> String {
self.title.clone().unwrap_or_default()
}
fn set_display_name(&mut self, name: &str) {
self.title = if name.is_empty() {
None
} else {
Some(name.to_owned())
}
}
fn update_object_queue_item(
&self,
revision_ts: Option<Revision>,
object: &CloudEnvVarCollection,
) -> QueueItem {
QueueItem::UpdateEnvVarCollection {
model: object.model().clone().into(),
id: object.id,
revision: revision_ts.or_else(|| object.metadata.revision.clone()),
}
}
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey> {
None
}
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
if let ServerCloudObject::EnvVarCollection(server_envvar_collection) = server_cloud_object {
return Some(server_envvar_collection.model.clone().string_model);
}
None
}
fn should_show_activity_toasts() -> bool {
true
}
fn warn_if_unsaved_at_quit() -> bool {
true
}
fn renders_in_warp_drive(&self) -> bool {
true
}
fn can_export(&self) -> bool {
true
}
fn supports_linking(&self) -> bool {
true
}
fn to_warp_drive_item(
&self,
id: SyncId,
_appearance: &Appearance,
env_var_collection: &CloudEnvVarCollection,
) -> Option<Box<dyn WarpDriveItem>> {
Some(Box::new(WarpDriveEnvVarCollection::new(
CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
id,
},
env_var_collection.clone(),
)))
}
}
impl JsonModel for EnvVarCollection {
fn json_object_type() -> JsonObjectType {
JsonObjectType::EnvVarCollection
}
}
impl PartialEq<CloudEnvVarCollection> for CloudEnvVarCollection {
fn eq(&self, other: &CloudEnvVarCollection) -> bool {
self.model().string_model == other.model().string_model && self.id == other.id
}
}
pub fn serialize_variables_for_shell<'s, I: IntoIterator<Item = (&'s str, &'s EnvVarValue)>>(
pairs: I,
shell_type: ShellType,
) -> String {
match shell_type {
// Warp doesn't support newlines in fish so we can't use env syntax
ShellType::Fish => {
serialize_variables_internal(pairs, "set -x ", " ", ";", " ", shell_type.into())
}
ShellType::Bash | ShellType::Zsh => {
serialize_variables_internal(pairs, "", "=", "", " ", shell_type.into())
}
ShellType::PowerShell => {
serialize_variables_internal(pairs, "$env:", " = ", ";", " ", shell_type.into())
}
}
}
// Prefix — what's prepended to each variable
// Separator — what separates the variable name from the value
// Postfix — what's appended to the end of each variable
// Delimeter — what separates one variable from the next one
// set -x var_name var_value; set -x name2 value2;
// ------ - - -
// ^ ^ ^ ^
// prefix separator postfix delimeter (in this case 4 spaces, usually one space or newline)
fn serialize_variables_internal<'s, I: IntoIterator<Item = (&'s str, &'s EnvVarValue)>>(
pairs: I,
prefix: &str,
separator: &str,
postfix: &str,
delimeter: &str,
shell_family: ShellFamily,
) -> String {
pairs
.into_iter()
.map(|(name, value)| {
format!(
"{}{}{}{}{}",
prefix,
shell_family.escape(name),
separator,
get_init_command_for_env_var(value, shell_family),
postfix
)
})
.collect_vec()
.join(delimeter)
}
@@ -0,0 +1,365 @@
use warp_core::ui::appearance::Appearance;
use warp_editor::editor::NavigationKey;
use warpui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, Flex, MouseStateHandle, ParentElement,
Radius, Shrinkable,
},
ui_components::{
button::ButtonVariant,
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::editor::{
EditorOptions, EditorView, Event, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
};
use super::EnvVarSecretCommand;
const COMMAND_EDITOR_MIN_LINES: f32 = 6.;
const SPAN_FONT_SIZE: f32 = 16.;
const BUTTON_FONT_SIZE: f32 = 14.;
const CORE_WIDTH: f32 = 400.;
const CORE_HEIGHT: f32 = 250.;
const EDITOR_FONT_SIZE: f32 = 14.;
const CONTAINER_PADDING: f32 = 25.;
const ELEMENT_SPACING: f32 = 10.;
const EDITOR_DIVIDE: f32 = 6.;
const SECRET_SPAN: &str = "Secret command";
const SAVE_BUTTON_LABEL: &str = "Save";
const CANCEL_BUTTON_LABEL: &str = "Cancel";
const NAME_PLACEHOLDER_TEXT: &str = "Name";
const COMMAND_PLACEHOLDER_TEXT: &str = "Command";
#[derive(Debug, Clone)]
pub enum EnvVarCommandDialogAction {
Close,
SaveCommand,
}
#[derive(Debug, Clone)]
pub enum EnvVarCommandDialogEvent {
Close,
SaveCommand(EnvVarSecretCommand),
}
#[derive(Default)]
struct MouseStateHandles {
cancel_button_mouse_state_handle: MouseStateHandle,
save_button_mouse_state_handle: MouseStateHandle,
}
pub struct EnvVarCommandDialog {
mouse_state_handles: MouseStateHandles,
name_editor: ViewHandle<EditorView>,
command_editor: ViewHandle<EditorView>,
}
impl EnvVarCommandDialog {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let name_editor = {
ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let options = SingleLineEditorOptions {
text: TextOptions::ui_text(Some(EDITOR_FONT_SIZE), appearance),
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text(NAME_PLACEHOLDER_TEXT, ctx);
editor
})
};
ctx.subscribe_to_view(&name_editor, |me, _, event, ctx| {
me.handle_name_editor_event(event, ctx);
});
let command_editor = {
ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let options = EditorOptions {
text: TextOptions {
font_size_override: Some(EDITOR_FONT_SIZE),
font_family_override: Some(appearance.monospace_font_family()),
..Default::default()
},
soft_wrap: true,
autogrow: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
supports_vim_mode: false,
single_line: false,
..Default::default()
};
let mut editor = EditorView::new(options, ctx);
editor.set_placeholder_text(COMMAND_PLACEHOLDER_TEXT, ctx);
editor
})
};
ctx.subscribe_to_view(&command_editor, |me, _, event, ctx| {
me.handle_command_editor_event(event, ctx);
});
Self {
mouse_state_handles: Default::default(),
name_editor,
command_editor,
}
}
fn handle_name_editor_event(&mut self, event: &Event, ctx: &mut ViewContext<Self>) {
match event {
Event::Navigate(NavigationKey::Tab) | Event::Navigate(NavigationKey::ShiftTab) => {
ctx.focus(&self.command_editor)
}
Event::Enter => {
if !self.should_disable_save(ctx) {
self.save_command_and_close(ctx)
}
}
Event::Escape => self.close(ctx),
_ => {}
}
}
fn handle_command_editor_event(&mut self, event: &Event, ctx: &mut ViewContext<Self>) {
match event {
Event::Navigate(NavigationKey::Tab) | Event::Navigate(NavigationKey::ShiftTab) => {
ctx.focus(&self.name_editor)
}
Event::Enter => {
if !self.should_disable_save(ctx) {
self.save_command_and_close(ctx)
}
}
Event::Escape => self.close(ctx),
Event::Edited(_) => ctx.notify(),
_ => {}
}
}
pub fn load(&mut self, secret_command: &EnvVarSecretCommand, ctx: &mut ViewContext<Self>) {
self.name_editor.update(ctx, |buffer, ctx| {
buffer.set_buffer_text(&secret_command.name, ctx)
});
self.command_editor.update(ctx, |buffer, ctx| {
buffer.set_buffer_text(&secret_command.command, ctx)
});
}
fn save_command_and_close(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(EnvVarCommandDialogEvent::SaveCommand(EnvVarSecretCommand {
name: self.name_editor.as_ref(ctx).buffer_text(ctx),
command: self.command_editor.as_ref(ctx).buffer_text(ctx),
}));
self.close(ctx);
}
fn close(&mut self, ctx: &mut ViewContext<Self>) {
self.name_editor
.update(ctx, |buffer, ctx| buffer.clear_buffer(ctx));
self.command_editor
.update(ctx, |buffer, ctx| buffer.clear_buffer(ctx));
ctx.emit(EnvVarCommandDialogEvent::Close)
}
fn should_disable_save(&self, app: &AppContext) -> bool {
self.command_editor.as_ref(app).is_empty(app)
}
fn render_button(
&self,
appearance: &Appearance,
button_mouse_state: MouseStateHandle,
action: EnvVarCommandDialogAction,
label_text: &str,
is_save: bool,
app: &AppContext,
) -> Box<dyn Element> {
let mut button = appearance
.ui_builder()
.button(
if is_save {
ButtonVariant::Accent
} else {
ButtonVariant::Secondary
},
button_mouse_state,
)
.with_centered_text_label(label_text.to_owned())
.with_style(UiComponentStyles {
font_size: Some(BUTTON_FONT_SIZE),
font_weight: Some(warpui::fonts::Weight::Normal),
..Default::default()
});
if is_save && self.should_disable_save(app) {
button = button.disabled();
};
button
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
.with_cursor(warpui::platform::Cursor::PointingHand)
.finish()
}
fn render_command_editor(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let line_height = self
.command_editor
.as_ref(app)
.line_height(app.font_cache(), appearance);
Container::new(
ConstrainedBox::new(
appearance
.ui_builder()
.text_input(self.command_editor.clone())
.with_style(UiComponentStyles {
..Default::default()
})
.build()
.finish(),
)
.with_height(COMMAND_EDITOR_MIN_LINES * line_height)
.finish(),
)
.with_margin_bottom(ELEMENT_SPACING)
.finish()
}
fn render_name_editor(&self, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
appearance
.ui_builder()
.text_input(self.name_editor.clone())
.with_style(UiComponentStyles::default())
.build()
.finish(),
)
.with_margin_bottom(EDITOR_DIVIDE)
.finish()
}
fn render_dialog_span(&self, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
appearance
.ui_builder()
.span(SECRET_SPAN)
.with_style(UiComponentStyles {
font_size: Some(SPAN_FONT_SIZE),
..Default::default()
})
.build()
.finish(),
)
.with_margin_bottom(ELEMENT_SPACING)
.finish()
}
}
impl Entity for EnvVarCommandDialog {
type Event = EnvVarCommandDialogEvent;
}
impl View for EnvVarCommandDialog {
fn ui_name() -> &'static str {
"EnvVarCommandDialog"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.name_editor);
ctx.notify();
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
ConstrainedBox::new(
Shrinkable::new(
1.,
Container::new(
Flex::column()
.with_child(self.render_dialog_span(appearance))
.with_child(self.render_name_editor(appearance))
.with_child(self.render_command_editor(appearance, app))
.with_child(
Flex::row()
.with_child(
Shrinkable::new(
1.,
Container::new(
self.render_button(
appearance,
self.mouse_state_handles
.cancel_button_mouse_state_handle
.clone(),
EnvVarCommandDialogAction::Close,
CANCEL_BUTTON_LABEL,
false,
app,
),
)
.with_margin_right(ELEMENT_SPACING)
.finish(),
)
.finish(),
)
.with_child(
Shrinkable::new(
1.,
self.render_button(
appearance,
self.mouse_state_handles
.save_button_mouse_state_handle
.clone(),
EnvVarCommandDialogAction::SaveCommand,
SAVE_BUTTON_LABEL,
true,
app,
),
)
.finish(),
)
.finish(),
)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_uniform_padding(CONTAINER_PADDING)
.with_border(Border::all(2.).with_border_fill(appearance.theme().surface_2()))
.with_background(appearance.theme().surface_1())
.finish(),
)
.finish(),
)
.with_max_width(CORE_WIDTH)
.with_height(CORE_HEIGHT)
.finish()
}
}
impl TypedActionView for EnvVarCommandDialog {
type Action = EnvVarCommandDialogAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
EnvVarCommandDialogAction::Close => self.close(ctx),
EnvVarCommandDialogAction::SaveCommand => self.save_command_and_close(ctx),
}
}
}
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
use warpui::ViewContext;
use super::env_var_collection::{EnvVarCollectionView, VariableRowIndex};
use crate::env_vars::{active_env_var_collection_data::SavingStatus, EnvVarValue};
mod command_dialog_view;
pub(super) use command_dialog_view::{EnvVarCommandDialog, EnvVarCommandDialogEvent};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EnvVarSecretCommand {
pub name: String,
pub command: String,
}
impl EnvVarCollectionView {
pub(super) fn display_command_dialog(
&mut self,
index: Option<VariableRowIndex>,
ctx: &mut ViewContext<Self>,
) {
if let Some(VariableRowIndex(index)) = index {
if let EnvVarValue::Command(cmd) = &self.variable_rows[index].value {
self.env_var_command_dialog
.update(ctx, |dialog, ctx| dialog.load(cmd, ctx))
}
}
self.dialog_open_states.env_var_command_dialog_open = true;
self.update_open_modal_state(ctx);
ctx.focus(&self.env_var_command_dialog);
ctx.notify();
}
pub(super) fn handle_command_dialog_event(
&mut self,
event: &EnvVarCommandDialogEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
EnvVarCommandDialogEvent::Close => {
self.dialog_open_states.env_var_command_dialog_open = false;
self.update_open_modal_state(ctx);
ctx.notify();
}
EnvVarCommandDialogEvent::SaveCommand(command) => {
self.save_command(command.clone(), ctx);
self.set_saving_status(SavingStatus::Unsaved, ctx)
}
}
}
fn save_command(&mut self, command: EnvVarSecretCommand, ctx: &mut ViewContext<Self>) {
let row_index = self.pending_variable_row_index.take();
if let Some(VariableRowIndex(index)) = row_index {
self.variable_rows[index].value = EnvVarValue::Command(command);
}
ctx.notify();
}
}
+521
View File
@@ -0,0 +1,521 @@
use warp_editor::editor::NavigationKey;
use warpui::{
elements::{
Align, ConstrainedBox, Container, Flex, ParentElement, SavePosition, Shrinkable, Stack,
},
fonts::FamilyId,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, ViewContext, ViewHandle,
};
use crate::{
editor::{
EditOrigin, EditorOptions, EditorView, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,
},
env_vars::{
active_env_var_collection_data::SavingStatus,
view::env_var_collection::{
EditorType, EnvVarCollectionView, DESCRIPTION_EDITOR_POSITION, ROW_SPACING,
},
EnvVarValue,
},
Appearance,
};
// Metadata labels (name and description)
const LABEL_FONT_SIZE: f32 = 12.;
const METADATA_SPACING: f32 = 8.;
const LAST_ROW_ELEMENT_SPACING: f32 = 2.;
const TITLE_LABEL_TEXT: &str = "Title";
const DESCRIPTION_LABEL_TEXT: &str = "Description";
const VERTICAL_TEXT_INPUT_PADDING: f32 = 5.;
const HORIZONTAL_TEXT_INPUT_PADDING: f32 = 10.;
const SECRET_ICON_BUTTON_MARGIN: f32 = 2.;
impl EnvVarCollectionView {
pub(super) fn create_editor_handle(
ctx: &mut ViewContext<Self>,
font_size_override: Option<f32>,
font_family_override: Option<FamilyId>,
placeholder_text: Option<&str>,
single_line: bool,
) -> ViewHandle<EditorView> {
let text = TextOptions {
font_size_override,
font_family_override,
..Default::default()
};
ctx.add_typed_action_view(|ctx| {
let mut editor = if single_line {
EditorView::single_line(
SingleLineEditorOptions {
text,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
},
ctx,
)
} else {
EditorView::new(
EditorOptions {
text,
soft_wrap: true,
autogrow: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
supports_vim_mode: false,
single_line: false,
..Default::default()
},
ctx,
)
};
if let Some(text) = placeholder_text {
editor.set_placeholder_text(text, ctx);
}
editor
})
}
pub(super) fn editors_are_empty(&self, app: &AppContext) -> bool {
self.variable_rows.iter().any(|row| {
row.variable_name_editor.as_ref(app).is_empty(app)
|| (row.variable_value_editor.as_ref(app).is_empty(app)
&& matches!(row.value, EnvVarValue::Constant(_)))
})
}
pub(super) fn handle_title_editor_event(
&mut self,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
EditorEvent::Navigate(NavigationKey::ShiftTab) => {
if self.variable_rows.is_empty() {
ctx.focus(&self.description_editor);
} else if let Some(variable_row) = self.variable_rows.last() {
ctx.focus(&variable_row.variable_description_editor);
}
}
EditorEvent::Navigate(NavigationKey::Tab) => {
ctx.focus(&self.description_editor);
}
EditorEvent::ClearParentSelections => {
self.clear_parent_selections(self.title_editor.clone(), ctx)
}
EditorEvent::Edited(EditOrigin::UserInitiated)
| EditorEvent::Edited(EditOrigin::UserTyped) => {
self.set_saving_status(SavingStatus::Unsaved, ctx);
let current_text = self.title_editor.as_ref(ctx).buffer_text(ctx);
self.update_title_validation(&current_text, ctx);
}
_ => {}
}
}
pub(super) fn handle_description_editor_event(
&mut self,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
EditorEvent::Navigate(NavigationKey::ShiftTab) => {
ctx.focus(&self.title_editor);
}
EditorEvent::Navigate(NavigationKey::Tab) => {
if self.variable_rows.is_empty() {
ctx.focus(&self.title_editor);
} else if let Some(variable_row) = self.variable_rows.first() {
ctx.focus(&variable_row.variable_name_editor);
}
}
EditorEvent::ClearParentSelections => {
self.clear_parent_selections(self.description_editor.clone(), ctx)
}
EditorEvent::Edited(EditOrigin::UserInitiated)
| EditorEvent::Edited(EditOrigin::UserTyped) => {
self.set_saving_status(SavingStatus::Unsaved, ctx);
let current_text = self.description_editor.as_ref(ctx).buffer_text(ctx);
self.update_description_validation(&current_text, ctx);
}
_ => {}
}
}
pub(super) fn handle_variable_event(
&mut self,
handle: ViewHandle<EditorView>,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
EditorEvent::Navigate(NavigationKey::ShiftTab) => {
self.focus_prev_variable_editor(handle, ctx)
}
EditorEvent::Navigate(NavigationKey::Tab) => {
self.focus_next_variable_editor(handle, ctx)
}
EditorEvent::ClearParentSelections => self.clear_parent_selections(handle.clone(), ctx),
EditorEvent::Edited(EditOrigin::UserInitiated)
| EditorEvent::Edited(EditOrigin::UserTyped) => {
self.set_saving_status(SavingStatus::Unsaved, ctx);
if let Some((row_index, field_type)) = self.find_editor_info(&handle) {
let current_text = handle.as_ref(ctx).buffer_text(ctx);
self.update_field_validation(row_index, field_type, &current_text, ctx);
}
ctx.notify()
}
_ => {}
}
}
fn clear_parent_selections(
&mut self,
editor: ViewHandle<EditorView>,
ctx: &mut ViewContext<Self>,
) {
if editor != self.title_editor {
self.title_editor.update(ctx, |editor, ctx| {
editor.clear_selections(ctx);
})
}
if editor != self.description_editor {
self.description_editor.update(ctx, |editor, ctx| {
editor.clear_selections(ctx);
})
}
self.variable_rows.iter().for_each(|var_editor| {
if var_editor.variable_name_editor != editor {
var_editor
.variable_name_editor
.update(ctx, |var_editor, ctx| {
var_editor.clear_selections(ctx);
});
}
if var_editor.variable_value_editor != editor {
var_editor
.variable_value_editor
.update(ctx, |var_editor, ctx| {
var_editor.clear_selections(ctx);
});
}
if var_editor.variable_description_editor != editor {
var_editor
.variable_description_editor
.update(ctx, |var_editor, ctx| {
var_editor.clear_selections(ctx);
});
}
})
}
fn focus_next_variable_editor(
&self,
handle: ViewHandle<EditorView>,
ctx: &mut ViewContext<Self>,
) {
let editor = self
.variable_rows
.iter()
.enumerate()
.find_map(|(index, editor)| {
if editor.variable_name_editor == handle {
Some((index, EditorType::Name))
} else if editor.variable_value_editor == handle {
Some((index, EditorType::Value))
} else if editor.variable_description_editor == handle {
Some((index, EditorType::Description))
} else {
None
}
});
match editor {
Some((index, EditorType::Name)) => {
if let Some(variable_row) = self.variable_rows.get(index) {
if let EnvVarValue::Constant(_) = variable_row.value {
ctx.focus(&variable_row.variable_value_editor);
} else {
ctx.focus(&variable_row.variable_description_editor);
}
}
}
Some((index, EditorType::Value)) => {
if let Some(variable_row) = self.variable_rows.get(index) {
ctx.focus(&variable_row.variable_description_editor);
}
}
Some((index, EditorType::Description)) => {
if index == self.variable_rows.len() - 1 {
ctx.focus(&self.title_editor)
} else if let Some(next_variable_row) = self.variable_rows.get(index + 1) {
ctx.focus(&next_variable_row.variable_name_editor)
}
}
_ => {}
};
}
fn focus_prev_variable_editor(
&self,
handle: ViewHandle<EditorView>,
ctx: &mut ViewContext<Self>,
) {
let editor = self
.variable_rows
.iter()
.enumerate()
.find_map(|(index, editor)| {
if editor.variable_name_editor == handle {
Some((index, EditorType::Name))
} else if editor.variable_value_editor == handle {
Some((index, EditorType::Value))
} else if editor.variable_description_editor == handle {
Some((index, EditorType::Description))
} else {
None
}
});
match editor {
Some((index, EditorType::Name)) => {
if index == 0 {
ctx.focus(&self.description_editor)
} else if let Some(prev_variable_row) = self.variable_rows.get(index - 1) {
ctx.focus(&prev_variable_row.variable_description_editor);
}
}
Some((index, EditorType::Value)) => {
if let Some(variable_row) = self.variable_rows.get(index) {
ctx.focus(&variable_row.variable_name_editor);
}
}
Some((index, EditorType::Description)) => {
if let Some(variable_row) = self.variable_rows.get(index) {
if let EnvVarValue::Constant(_) = variable_row.value {
ctx.focus(&variable_row.variable_value_editor);
} else {
ctx.focus(&variable_row.variable_name_editor);
}
}
}
_ => {}
};
}
fn render_metadata_label<S>(&self, text: S, appearance: &Appearance) -> Box<dyn Element>
where
S: Into<String>,
{
appearance
.ui_builder()
.span(text.into())
.with_style(UiComponentStyles {
font_size: Some(LABEL_FONT_SIZE),
..Default::default()
})
.build()
.finish()
}
fn render_metadata_editor(
&self,
appearance: &Appearance,
editor: ViewHandle<EditorView>,
has_error: bool,
) -> Box<dyn Element> {
let mut style = UiComponentStyles {
padding: Some(Coords {
left: HORIZONTAL_TEXT_INPUT_PADDING,
right: HORIZONTAL_TEXT_INPUT_PADDING,
top: VERTICAL_TEXT_INPUT_PADDING,
bottom: VERTICAL_TEXT_INPUT_PADDING,
}),
..Default::default()
};
if has_error {
let error_color = appearance.theme().ui_error_color();
style.border_color = Some(error_color.into());
style.border_width = Some(super::env_var_collection::ERROR_BORDER_WIDTH);
}
appearance
.ui_builder()
.text_input(editor.clone())
.with_style(style)
.build()
.finish()
}
// "Metadata" references the object level title and description fields
pub(super) fn render_metadata(&self, appearance: &Appearance) -> Box<dyn Element> {
let title_has_error = self.form_validation_state.title_error.is_some();
let description_has_error = self.form_validation_state.description_error.is_some();
Flex::column()
.with_child(
Container::new(self.render_metadata_label(TITLE_LABEL_TEXT, appearance))
.with_margin_bottom(METADATA_SPACING)
.finish(),
)
.with_child(
Container::new(self.render_metadata_editor(
appearance,
self.title_editor.clone(),
title_has_error,
))
.with_margin_bottom(METADATA_SPACING)
.finish(),
)
.with_child(
SavePosition::new(
Container::new(self.render_metadata_label(DESCRIPTION_LABEL_TEXT, appearance))
.with_margin_bottom(METADATA_SPACING)
.finish(),
DESCRIPTION_EDITOR_POSITION,
)
.finish(),
)
.with_child(
Container::new(self.render_metadata_editor(
appearance,
self.description_editor.clone(),
description_has_error,
))
.finish(),
)
.finish()
}
pub(super) fn render_variable_editor(
&self,
appearance: &Appearance,
editor: ViewHandle<EditorView>,
editor_type: EditorType,
inline_secret_button: Option<Box<dyn Element>>,
row_index: Option<usize>,
) -> Box<dyn Element> {
let margin_right = if editor_type != EditorType::Description {
ROW_SPACING
} else {
LAST_ROW_ELEMENT_SPACING
};
let validation_error = if let Some(index) = row_index {
self.variable_rows
.get(index)
.and_then(|row| row.validation_state.get_field_error(editor_type))
} else {
None
};
let text_input = {
let mut style = UiComponentStyles {
padding: Some(Coords {
left: HORIZONTAL_TEXT_INPUT_PADDING,
right: HORIZONTAL_TEXT_INPUT_PADDING,
top: VERTICAL_TEXT_INPUT_PADDING,
bottom: VERTICAL_TEXT_INPUT_PADDING,
}),
..Default::default()
};
if validation_error.is_some() {
let error_color = appearance.theme().ui_error_color();
style.border_color = Some(error_color.into());
style.border_width = Some(super::env_var_collection::ERROR_BORDER_WIDTH);
}
appearance
.ui_builder()
.text_input(editor.clone())
.with_style(style)
.build()
.finish()
};
let input_container = {
let mut stack = Stack::new().with_child(text_input);
if let Some(element) = inline_secret_button {
stack.add_child(
Align::new(
Container::new(element)
.with_margin_right(SECRET_ICON_BUTTON_MARGIN)
.with_margin_top(SECRET_ICON_BUTTON_MARGIN)
.finish(),
)
.right()
.finish(),
);
}
stack.finish()
};
let editor_column = input_container;
Shrinkable::new(
1.,
Container::new(ConstrainedBox::new(editor_column).finish())
.with_margin_right(margin_right)
.finish(),
)
.finish()
}
/// Sync all editors with the user's access level. If the env var collection is view-only, all
/// editors are set to selection-only mode. Otherwise, all are enabled.
pub(super) fn update_editor_interactivity(&mut self, ctx: &mut ViewContext<Self>) {
let editability = self
.active_env_var_collection_data
.as_ref(ctx)
.editability(ctx);
let interaction_state = if editability.can_edit() {
InteractionState::Editable
} else {
InteractionState::Selectable
};
// Update metadata editors.
self.title_editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(interaction_state, ctx)
});
self.description_editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(interaction_state, ctx)
});
// Update individual variable editors.
for variable_row in self.variable_rows.iter() {
variable_row
.variable_name_editor
.update(ctx, |editor, ctx| {
editor.set_interaction_state(interaction_state, ctx)
});
variable_row
.variable_description_editor
.update(ctx, |editor, ctx| {
editor.set_interaction_state(interaction_state, ctx)
});
variable_row
.variable_value_editor
.update(ctx, |editor, ctx| {
editor.set_interaction_state(interaction_state, ctx)
});
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
use warp_core::ui::appearance::Appearance;
use warpui::{platform::WindowStyle, App, ViewHandle};
use crate::auth::AuthStateProvider;
use crate::{
cloud_object::model::{actions::ObjectActions, persistence::CloudModel, view::CloudViewModel},
env_vars::{
active_env_var_collection_data::SavingStatus,
view::env_var_collection::EnvVarCollectionView,
},
network::NetworkStatus,
server::{
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
sync_queue::SyncQueue,
},
settings_view::keybindings::KeybindingChangedNotifier,
test_util::settings::initialize_settings_for_tests,
workspace::ActiveSession,
workspaces::{
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
},
GlobalResourceHandles, GlobalResourceHandlesProvider,
};
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
let global_resources = GlobalResourceHandles::mock(app);
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resources));
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudViewModel::mock);
app.add_singleton_model(|_| UserProfiles::new(vec![]));
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| ActiveSession::default());
app.add_singleton_model(|_| ObjectActions::new(Vec::new()));
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
#[cfg(feature = "voice_input")]
app.add_singleton_model(voice_input::VoiceInput::new);
}
fn create_env_var_collection_view(app: &mut App) -> ViewHandle<EnvVarCollectionView> {
initialize_app(app);
let (_, env_var_collection_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
EnvVarCollectionView::new(ctx)
});
env_var_collection_view
}
#[test]
fn test_variable_row_addition_and_removal() {
App::test((), |mut app| async move {
let env_var_collection_view = create_env_var_collection_view(&mut app);
env_var_collection_view.update(&mut app, |view, ctx| {
view.open_new_env_var_collection(
crate::cloud_object::Owner::mock_current_user(),
None,
ctx,
);
});
// New EVCs should open with a new row
env_var_collection_view.read(&app, |view, _| {
assert_eq!(view.variable_rows.len(), 1);
});
env_var_collection_view.update(&mut app, |view, ctx| {
view.add_variable_row(ctx);
});
env_var_collection_view.update(&mut app, |view, ctx| {
view.variable_rows[1]
.variable_description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_1", ctx);
});
view.delete_row(0, ctx);
});
env_var_collection_view.read(&app, |view, ctx| {
assert_eq!(view.variable_rows.len(), 1);
assert_eq!(
view.variable_rows[0]
.variable_description_editor
.as_ref(ctx)
.buffer_text(ctx),
"description for foo_1".to_owned()
)
});
});
}
#[test]
fn test_saving_status() {
App::test((), |mut app| async move {
let env_var_collection_view = create_env_var_collection_view(&mut app);
env_var_collection_view.read(&app, |view, ctx| {
assert_eq!(
view.active_env_var_collection_data
.as_ref(ctx)
.saving_status,
SavingStatus::Saved
);
});
env_var_collection_view.update(&mut app, |view, ctx| {
view.add_variable_row(ctx);
});
env_var_collection_view.read(&app, |view, ctx| {
assert_eq!(
view.active_env_var_collection_data
.as_ref(ctx)
.saving_status,
SavingStatus::Unsaved
);
});
env_var_collection_view.update(&mut app, |view, ctx| {
view.active_env_var_collection_data.update(ctx, |data, _| {
data.saving_status = SavingStatus::Saved;
})
});
env_var_collection_view.update(&mut app, |view, ctx| {
view.delete_row(0, ctx);
});
env_var_collection_view.read(&app, |view, ctx| {
assert_eq!(
view.active_env_var_collection_data
.as_ref(ctx)
.saving_status,
SavingStatus::Unsaved
);
});
});
}
#[test]
fn test_should_disable_save() {
App::test((), |mut app| async move {
let env_var_collection_view = create_env_var_collection_view(&mut app);
env_var_collection_view.read(&app, |view, ctx| {
assert!(view.should_disable_save(ctx));
});
env_var_collection_view.update(&mut app, |view, ctx| {
view.add_variable_row(ctx);
});
env_var_collection_view.read(&app, |view, ctx| {
assert!(view.should_disable_save(ctx));
});
env_var_collection_view.update(&mut app, |view, ctx| {
view.variable_rows[0]
.variable_name_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("Test", ctx);
});
view.variable_rows[0]
.variable_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("Test", ctx);
})
});
env_var_collection_view.read(&app, |view, ctx| {
assert!(!view.should_disable_save(ctx));
});
env_var_collection_view.update(&mut app, |view, ctx| {
view.variable_rows[0]
.variable_value_editor
.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
})
});
env_var_collection_view.read(&app, |view, ctx| {
assert!(view.should_disable_save(ctx));
});
});
}
@@ -0,0 +1,315 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use warp_core::features::FeatureFlag;
use warpui::{
elements::{
Align, ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment,
MainAxisSize, ParentElement, Rect, Shrinkable, Stack,
},
fonts::Weight,
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{UiComponent, UiComponentStyles},
},
Element, ViewContext,
};
use crate::{
drive::sharing::{ContentEditability, SharingAccessLevel},
env_vars::{
active_env_var_collection_data::TrashStatus,
view::env_var_collection::{EnvVarCollectionAction, EnvVarCollectionView},
},
ui_components::{breadcrumb::BreadcrumbState, buttons::icon_button, icons::Icon},
AppContext, Appearance, SingletonEntity,
};
const VARIABLE_DIVIDER_HEIGHT: f32 = 2.;
const SECTION_FONT_SIZE: f32 = 16.;
const BUTTON_HEIGHT: f32 = 32.;
const SAVE_BUTTON_TEXT: &str = "Save";
const VARIABLES_LABEL_TEXT: &str = "Variables";
/// This file contains components that fixed in the view,
/// i.e. the trash banner, breadcrumbs, and variables section header
impl EnvVarCollectionView {
pub(super) fn update_breadcrumbs(&mut self, ctx: &mut ViewContext<Self>) {
self.breadcrumbs = self
.active_env_var_collection_data
.update(ctx, |data, ctx| {
data.breadcrumbs(ctx)
.map(|breadcrumbs| breadcrumbs.into_iter().map(BreadcrumbState::new).collect())
.unwrap_or_default()
})
}
pub(super) fn render_trash_banner(
&self,
access_level: SharingAccessLevel,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let deleted = match self
.active_env_var_collection_data
.as_ref(app)
.trash_status(app)
{
TrashStatus::Active => return None,
TrashStatus::Trashed => false,
TrashStatus::Deleted => true,
};
let appearance = Appearance::as_ref(app);
let mut stack = Stack::new();
let text = if deleted {
"You no longer have access to these environment variables"
} else {
"Environment variables were moved to trash"
};
stack.add_child(
Align::new(
Flex::row()
.with_children([
ConstrainedBox::new(
Icon::Trash
.to_warpui_icon(appearance.theme().foreground())
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
appearance
.ui_builder()
.span(text)
.with_style(UiComponentStyles {
font_size: Some(appearance.ui_font_size() + 2.),
..Default::default()
})
.build()
.with_padding_left(8.)
.finish(),
])
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.finish(),
)
.finish(),
);
let action_row = if deleted {
Shrinkable::new(1., Empty::new().finish()).finish()
} else {
let mut action_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if !FeatureFlag::SharedWithMe.is_enabled() || access_level.can_trash() {
let ui_builder = appearance.ui_builder().clone();
action_row.add_child(
Align::new(
appearance
.ui_builder()
.button(
ButtonVariant::Basic,
self.button_mouse_states.restore_from_trash_button.clone(),
)
.with_tooltip(move || {
ui_builder
.tool_tip(
"Restore environment variables from trash".to_string(),
)
.build()
.finish()
})
.with_text_label("Restore".to_string())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(EnvVarCollectionAction::Untrash)
})
.finish(),
)
.finish(),
);
}
action_row.finish()
};
stack.add_child(Align::new(action_row).right().finish());
Some(
Container::new(
ConstrainedBox::new(stack.finish())
.with_min_height(40.)
.finish(),
)
.with_horizontal_padding(16.)
.with_background(appearance.theme().surface_2())
.finish(),
)
}
pub(super) fn render_variables_section_header(
&self,
editability: ContentEditability,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut variables_section_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
variables_section_row.add_child(
Shrinkable::new(
2.,
appearance
.ui_builder()
.span(VARIABLES_LABEL_TEXT.to_string())
.with_style(UiComponentStyles {
font_size: Some(SECTION_FONT_SIZE),
..Default::default()
})
.build()
.finish(),
)
.finish(),
);
if !FeatureFlag::SharedWithMe.is_enabled() || editability.can_edit() {
variables_section_row.add_child(
Shrinkable::new(
1.,
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
icon_button(
appearance,
Icon::Plus,
false,
self.button_mouse_states.add_variable_state.clone(),
)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(EnvVarCollectionAction::AddVariable)
})
.finish(),
)
.finish(),
)
.finish(),
);
}
variables_section_row.finish()
}
pub(super) fn render_divider(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
Shrinkable::new(
1.,
ConstrainedBox::new(
Rect::new()
.with_background_color(if index != self.variable_rows.len() - 1 {
appearance.theme().surface_2().into()
} else {
ColorU::transparent_black()
})
.finish(),
)
.with_height(VARIABLE_DIVIDER_HEIGHT)
.finish(),
)
.finish()
}
pub(super) fn render_invoke_button(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let mut button = appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.button_mouse_states.invoke_mouse_state.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Bold),
width: Some(80.),
height: Some(BUTTON_HEIGHT),
..Default::default()
})
.with_text_and_icon_label(
TextAndIcon::new(
TextAndIconAlignment::TextFirst,
"Load",
Icon::TerminalInput.to_warpui_icon(appearance.theme().active_ui_text_color()),
MainAxisSize::Min,
MainAxisAlignment::SpaceBetween,
Vector2F::new(10., 10.),
)
.with_inner_padding(4.),
);
if self.should_disable_invoke(app) {
button = button.disabled();
}
button
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(EnvVarCollectionAction::Invoke))
.finish()
}
pub(super) fn render_save_button(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let is_save_disabled = self.should_disable_save(app);
let mut button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.button_mouse_states.save_mouse_state.clone(),
)
.with_style(UiComponentStyles {
font_color: if is_save_disabled {
Some(
appearance
.theme()
.disabled_text_color(appearance.theme().background())
.into_solid(),
)
} else {
Some(
appearance
.theme()
.main_text_color(appearance.theme().accent())
.into_solid(),
)
},
font_weight: Some(Weight::Bold),
width: Some(100.),
height: Some(BUTTON_HEIGHT),
font_size: Some(14.),
..Default::default()
})
.with_centered_text_label(SAVE_BUTTON_TEXT.to_owned());
if is_save_disabled {
button = button.disabled();
}
button
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EnvVarCollectionAction::SaveVariables)
})
.finish()
}
}
+496
View File
@@ -0,0 +1,496 @@
use pathfinder_geometry::vector::Vector2F;
use warp_core::context_flag::ContextFlag;
use warpui::{keymap::Trigger, SingletonEntity, ViewContext, ViewHandle};
use crate::{
cloud_object::{CloudObject, GenericStringObjectFormat, Space},
drive::{
drive_helpers::has_feature_gated_anonymous_user_reached_env_var_limit,
export::ExportManager, CloudObjectTypeAndId,
},
env_vars::active_env_var_collection_data::TrashStatus,
external_secrets::SecretManager,
menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields},
pane_group::PaneEvent,
server::cloud_objects::update_manager::UpdateManager,
ui_components::icons::Icon,
util::bindings::{keybinding_name_to_display_string, trigger_to_keystroke, CustomAction},
AppContext, CloudModel, FeatureFlag,
};
use super::env_var_collection::{EnvVarCollectionAction, EnvVarCollectionView, VariableRowIndex};
const PANE_MENU_WIDTH: f32 = 200.;
pub struct Menus {
pub(super) secret_menu: ViewHandle<Menu<EnvVarCollectionAction>>,
pub(super) rendered_secret_menu: ViewHandle<Menu<EnvVarCollectionAction>>,
pub(super) rendered_command_menu: ViewHandle<Menu<EnvVarCollectionAction>>,
pub(super) pane_context_menu: ViewHandle<Menu<EnvVarCollectionAction>>,
}
impl EnvVarCollectionView {
pub(super) fn initialize_menus(ctx: &mut ViewContext<Self>) -> Menus {
let command_item = Self::item(
"Command",
EnvVarCollectionAction::DisplayCommandDialog,
None,
Some(Icon::Terminal),
);
let one_password_item = Self::item(
"1Password",
EnvVarCollectionAction::SelectSecretManager(SecretManager::OnePassword),
None,
Some(Icon::OnePassword),
);
let lastpass_item = Self::item(
"LastPass",
EnvVarCollectionAction::SelectSecretManager(SecretManager::LastPass),
None,
Some(Icon::LastPass),
);
let edit_item = Self::item(
"Edit",
EnvVarCollectionAction::EditCommand,
None,
Some(Icon::Terminal),
);
let clear_secret_item = Self::item(
"Clear secret",
EnvVarCollectionAction::ClearSecret,
None,
Some(Icon::Trash),
);
let separator = MenuItem::Separator;
let secret_menu = Self::menu(
vec![
command_item.clone(),
one_password_item.clone(),
lastpass_item.clone(),
],
None,
ctx,
);
let rendered_secret_menu = Self::menu(
vec![
command_item.clone(),
one_password_item.clone(),
lastpass_item.clone(),
separator.clone(),
clear_secret_item.clone(),
],
None,
ctx,
);
let rendered_command_menu = Self::menu(
vec![
edit_item,
one_password_item,
lastpass_item,
separator,
clear_secret_item,
],
None,
ctx,
);
ctx.subscribe_to_view(&secret_menu, |me, _, event, ctx| {
me.handle_secret_menu_event(event, ctx);
});
ctx.subscribe_to_view(&rendered_secret_menu, |me, _, event, ctx| {
me.handle_rendered_secret_menu_event(event, ctx);
});
ctx.subscribe_to_view(&rendered_command_menu, |me, _, event, ctx| {
me.handle_rendered_command_menu_event(event, ctx);
});
let pane_context_menu = Self::menu(Vec::new(), Some(PANE_MENU_WIDTH), ctx);
Menus {
secret_menu,
rendered_secret_menu,
rendered_command_menu,
pane_context_menu,
}
}
pub(super) fn initialize_pane_context_menu(
&self,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<Menu<EnvVarCollectionAction>> {
let split_pane_right = Self::item(
"Split pane right",
EnvVarCollectionAction::EmitPaneEvent(PaneEvent::SplitRight(None)),
keybinding_name_to_display_string("pane_group:add_right", ctx),
None,
);
let split_pane_left = Self::item(
"Split pane left",
EnvVarCollectionAction::EmitPaneEvent(PaneEvent::SplitLeft(None)),
keybinding_name_to_display_string("pane_group:add_left", ctx),
None,
);
let split_pane_down = Self::item(
"Split pane down",
EnvVarCollectionAction::EmitPaneEvent(PaneEvent::SplitDown(None)),
keybinding_name_to_display_string("pane_group:add_down", ctx),
None,
);
let split_pane_up = Self::item(
"Split pane up",
EnvVarCollectionAction::EmitPaneEvent(PaneEvent::SplitUp(None)),
keybinding_name_to_display_string("pane_group:add_up", ctx),
None,
);
let is_maximized = self
.focus_handle
.as_ref()
.is_some_and(|handle| handle.split_pane_state(ctx).is_maximized());
let toggle_maximize_pane = Self::item(
if is_maximized {
"Minimize pane"
} else {
"Maximize pane"
},
EnvVarCollectionAction::EmitPaneEvent(PaneEvent::ToggleMaximized),
keybinding_name_to_display_string("pane_group:toggle_maximize_pane", ctx),
None,
);
let close_pane = Self::item(
"Close pane",
EnvVarCollectionAction::EmitPaneEvent(PaneEvent::Close),
trigger_to_keystroke(&Trigger::Custom(CustomAction::CloseCurrentSession.into()))
.map(|keystroke| keystroke.displayed()),
None,
);
let mut items = Vec::new();
if ContextFlag::CreateNewSession.is_enabled() {
items.extend(vec![
split_pane_right,
split_pane_left,
split_pane_down,
split_pane_up,
]);
}
if self
.focus_handle
.as_ref()
.is_some_and(|handle| handle.is_in_split_pane(ctx))
{
items.extend(vec![toggle_maximize_pane, close_pane]);
}
let pane_context_menu = Self::menu(items, Some(PANE_MENU_WIDTH), ctx);
ctx.subscribe_to_view(&pane_context_menu, |me, _, event, ctx| {
me.handle_pane_context_menu_event(event, ctx);
});
pane_context_menu
}
pub(super) fn display_secret_menu(&mut self, index: usize) {
let row = &mut self.variable_rows[index];
row.secret_menu_is_focused = true;
self.pending_variable_row_index = Some(VariableRowIndex(index));
}
pub(super) fn display_rendered_secret_menu(&mut self, index: usize) {
let row = &mut self.variable_rows[index];
row.rendered_secret_menu_is_focused = true;
self.pending_variable_row_index = Some(VariableRowIndex(index));
}
pub(super) fn display_rendered_command_menu(&mut self, index: usize) {
let row = &mut self.variable_rows[index];
row.rendered_command_menu_is_focused = true;
self.pending_variable_row_index = Some(VariableRowIndex(index));
}
pub(super) fn display_pane_context_menu(
&mut self,
position: &Vector2F,
ctx: &mut ViewContext<Self>,
) {
if let Some(parent_bounds) = ctx.element_position_by_id(self.view_position_id.clone()) {
self.menus.pane_context_menu = self.initialize_pane_context_menu(ctx);
let offset = *position - parent_bounds.origin();
self.pane_context_menu_offset = Some(offset);
ctx.notify();
}
}
fn handle_secret_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
match event {
MenuEvent::Close { via_select_item: _ } => self.reset_secret_menu(ctx),
MenuEvent::ItemSelected => self.reset_secret_menu(ctx),
MenuEvent::ItemHovered => {}
}
}
fn handle_rendered_secret_menu_event(
&mut self,
event: &MenuEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
MenuEvent::Close { via_select_item: _ } => self.reset_rendered_secret_menu(ctx),
MenuEvent::ItemSelected => self.reset_rendered_secret_menu(ctx),
MenuEvent::ItemHovered => {}
}
}
fn handle_rendered_command_menu_event(
&mut self,
event: &MenuEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
MenuEvent::Close { via_select_item: _ } => self.reset_rendered_command_menu(ctx),
MenuEvent::ItemSelected => self.reset_rendered_command_menu(ctx),
MenuEvent::ItemHovered => {}
}
}
fn handle_pane_context_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
match event {
MenuEvent::Close { via_select_item: _ } | MenuEvent::ItemSelected => {
self.pane_context_menu_offset = None;
self.menus
.pane_context_menu
.update(ctx, |menu, ctx| menu.reset_selection(ctx));
ctx.notify()
}
MenuEvent::ItemHovered => {}
}
}
fn reset_secret_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.variable_rows.iter_mut().for_each(|row| {
row.secret_menu_is_focused = false;
});
self.menus
.secret_menu
.update(ctx, |menu, ctx| menu.reset_selection(ctx));
ctx.notify()
}
fn reset_rendered_secret_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.variable_rows.iter_mut().for_each(|row| {
row.rendered_secret_menu_is_focused = false;
});
self.menus
.rendered_secret_menu
.update(ctx, |menu, ctx| menu.reset_selection(ctx));
ctx.notify()
}
fn reset_rendered_command_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.variable_rows.iter_mut().for_each(|row| {
row.rendered_command_menu_is_focused = false;
});
self.menus
.rendered_command_menu
.update(ctx, |menu, ctx| menu.reset_selection(ctx));
ctx.notify()
}
fn menu(
items: Vec<MenuItem<EnvVarCollectionAction>>,
width: Option<f32>,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<Menu<EnvVarCollectionAction>> {
ctx.add_typed_action_view(|_| {
let mut menu = Menu::new()
.prevent_interaction_with_other_elements()
.with_drop_shadow();
if let Some(width) = width {
menu = menu.with_width(width);
}
menu.add_items(items);
menu
})
}
fn item(
name: &str,
action: EnvVarCollectionAction,
key_shortcut: Option<String>,
icon: Option<Icon>,
) -> MenuItem<EnvVarCollectionAction> {
let mut field = MenuItemFields::new(name)
.with_on_select_action(action)
.with_key_shortcut_label(key_shortcut);
if let Some(icon) = icon {
field = field.with_icon(icon);
}
field.into_item()
}
// Used for duplicate, copy link, trash etc
pub(super) fn overflow_menu_items(
&self,
ctx: &AppContext,
) -> Vec<MenuItem<EnvVarCollectionAction>> {
let mut menu_items = Vec::new();
let active_collection_data = self.active_env_var_collection_data.as_ref(ctx);
let access_level = active_collection_data.access_level(ctx);
let space = active_collection_data.space(ctx);
if !active_collection_data.is_on_server()
|| active_collection_data.trash_status(ctx) != TrashStatus::Active
{
return menu_items;
}
// Add "Copy Link" to menu
if let Some(link) = self.env_var_collection_link(ctx) {
menu_items.push(
MenuItemFields::new("Copy link")
.with_on_select_action(EnvVarCollectionAction::CopyLink(link))
.with_icon(Icon::Link)
.into_item(),
);
}
// Add "Duplicate" to menu
if space != Some(Space::Shared) {
menu_items.push(
MenuItemFields::new("Duplicate")
.with_on_select_action(EnvVarCollectionAction::Duplicate)
.with_icon(Icon::Duplicate)
.into_item(),
);
}
// Add "Trash" to menu
if self.is_online(ctx)
&& (!FeatureFlag::SharedWithMe.is_enabled() || access_level.can_trash())
{
menu_items.push(
MenuItemFields::new("Trash")
.with_on_select_action(EnvVarCollectionAction::Trash)
.with_icon(Icon::Trash)
.into_item(),
);
}
#[cfg(feature = "local_fs")]
menu_items.push(
MenuItemFields::new("Export")
.with_on_select_action(EnvVarCollectionAction::Export)
.with_icon(Icon::Download)
.into_item(),
);
menu_items
}
pub(super) fn env_var_collection_link(&self, ctx: &AppContext) -> Option<String> {
self.env_var_collection_id(ctx)
.and_then(|id| CloudModel::as_ref(ctx).get_env_var_collection(&id))
.map(|env_var_collection| env_var_collection.object_link())?
}
pub(super) fn untrash_env_var_collection(&self, ctx: &mut ViewContext<Self>) {
if let Some(env_var_collection_id) = self.active_env_var_collection_data.as_ref(ctx).id() {
if has_feature_gated_anonymous_user_reached_env_var_limit(ctx) {
return;
}
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.untrash_object(
CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(
crate::cloud_object::JsonObjectType::EnvVarCollection,
),
id: env_var_collection_id,
},
ctx,
);
});
}
ctx.notify();
}
pub(super) fn trash_env_var_collection(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(env_var_collection_id) = self.env_var_collection_id(ctx) {
self.close_env_var_collection(ctx);
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.trash_object(
CloudObjectTypeAndId::from_generic_string_object(
GenericStringObjectFormat::Json(
crate::cloud_object::JsonObjectType::EnvVarCollection,
),
env_var_collection_id,
),
ctx,
);
});
ctx.notify();
}
}
pub(super) fn duplicate_env_var_collection(&self, ctx: &mut ViewContext<Self>) {
if let Some(env_var_collection_id) = self.env_var_collection_id(ctx) {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.duplicate_object(
&CloudObjectTypeAndId::from_generic_string_object(
GenericStringObjectFormat::Json(
crate::cloud_object::JsonObjectType::EnvVarCollection,
),
env_var_collection_id,
),
ctx,
);
});
ctx.notify();
}
}
pub(super) fn export_env_var_collection(&self, ctx: &mut ViewContext<Self>) {
if let Some(env_var_collection_id) = self.env_var_collection_id(ctx) {
let window_id = ctx.window_id();
ExportManager::handle(ctx).update(ctx, |export_manager, ctx| {
export_manager.export(
window_id,
&[CloudObjectTypeAndId::from_generic_string_object(
GenericStringObjectFormat::Json(
crate::cloud_object::JsonObjectType::EnvVarCollection,
),
env_var_collection_id,
)],
ctx,
)
});
}
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod command_dialog;
pub mod editors;
pub mod env_var_collection;
#[cfg(test)]
mod env_var_collection_tests;
pub mod fixed_view_components;
pub mod menus;
pub mod secrets;
pub mod unsaved_changes_dialog;
+245
View File
@@ -0,0 +1,245 @@
use pathfinder_geometry::vector::vec2f;
use warp_core::{features::FeatureFlag, ui::appearance::Appearance};
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
use warpui::{
elements::{
ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, Empty, Fill, MainAxisAlignment,
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Shrinkable, Stack,
},
fonts::Weight,
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{UiComponent, UiComponentStyles},
},
Element, ViewContext,
};
use super::env_var_collection::{
EnvVarCollectionAction, EnvVarCollectionView, VariableRowIndex, CORE_MAX_WIDTH, ROW_SPACING,
};
use crate::{
drive::sharing::ContentEditability,
env_vars::{active_env_var_collection_data::SavingStatus, EnvVarValue},
external_secrets::{ExternalSecretManager, SecretManager},
search::external_secrets::{
searcher::ExternalSecretSearchItemAction, view::ExternalSecretsMenuEvent,
},
ui_components::icons::Icon,
};
#[cfg(all(not(target_family = "wasm"), feature = "local_tty"))]
use crate::{
terminal::local_shell::LocalShellState,
view_components::{DismissibleToast, ToastLink},
workspace::{ToastStack, WorkspaceAction},
};
impl EnvVarCollectionView {
pub(super) fn handle_external_secrets_dialog_event(
&mut self,
event: &ExternalSecretsMenuEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ExternalSecretsMenuEvent::ItemSelected { payload } => {
let ExternalSecretSearchItemAction::AcceptSecret(secret) = payload.as_ref();
let row_index = self.pending_variable_row_index.take();
if let Some(VariableRowIndex(index)) = row_index {
self.variable_rows[index].value = EnvVarValue::Secret(secret.clone());
}
self.set_saving_status(SavingStatus::Unsaved, ctx)
}
ExternalSecretsMenuEvent::Close => {
self.dialog_open_states.secrets_dialog_open = false;
self.update_open_modal_state(ctx);
ctx.focus_self();
ctx.notify();
}
ExternalSecretsMenuEvent::Open => {
self.dialog_open_states.secrets_dialog_open = true;
ctx.focus(&self.secrets_dialog);
self.update_open_modal_state(ctx);
ctx.notify();
}
}
}
pub(super) fn clear_secret(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(VariableRowIndex(index)) = self.pending_variable_row_index.take() {
self.variable_rows[index].value = EnvVarValue::Constant(String::new());
}
ctx.notify();
}
#[cfg_attr(target_family = "wasm", allow(unused_variables))]
pub(super) fn fetch_secret(
&mut self,
secret_manager: SecretManager,
ctx: &mut ViewContext<Self>,
) {
#[cfg(all(not(target_family = "wasm"), feature = "local_tty"))]
{
let window_id = ctx.window_id();
let local_shell = LocalShellState::as_ref(ctx);
let secret_manager_clone = secret_manager.clone();
let Some(local_shell_state) = local_shell.local_shell_info() else {
return;
};
let shell_type = local_shell_state.get_shell_type();
let shell_path = local_shell_state.get_shell_path().clone();
let path_env_var = local_shell_state.get_path_env_var().clone();
self.secrets_dialog.update(ctx, |_, dialog_ctx| {
let _ = dialog_ctx.spawn(
async move {
secret_manager
.verify_installed_and_fetch_secrets(
shell_type,
shell_path,
path_env_var,
)
.await
},
move |view, result, dialog_ctx| match result {
Ok(secrets) => {
view.setup(secrets, dialog_ctx);
}
Err(e) => {
let error_message_and_command =
secret_manager_clone.get_toast_message_and_link(e);
let mut toast =
DismissibleToast::error(error_message_and_command.message);
if let (Some(link), Some(link_message)) = (
error_message_and_command.link,
error_message_and_command.link_message,
) {
toast = toast.with_link(
ToastLink::new(link_message)
.with_onclick_action(WorkspaceAction::OpenLink(link)),
);
}
ToastStack::handle(dialog_ctx).update(dialog_ctx, |toast_stack, ctx| {
toast_stack.add_persistent_toast(toast, window_id, ctx);
})
}
},
);
});
}
}
pub(super) fn render_secret_or_command_button(
&self,
appearance: &Appearance,
secret: &EnvVarValue,
menu_button_mouse_state: MouseStateHandle,
row_index: usize,
is_focused: bool,
editability: ContentEditability,
) -> Box<dyn Element> {
let (display_name, action, menu, icon) = match secret {
EnvVarValue::Secret(sec) => (
sec.get_display_name(),
EnvVarCollectionAction::DisplayRenderedSecretMenu(VariableRowIndex(row_index)),
&self.menus.rendered_secret_menu,
sec.icon(),
),
EnvVarValue::Command(cmd) => (
if !cmd.name.is_empty() {
cmd.name.clone()
} else {
cmd.command.clone()
},
EnvVarCollectionAction::DisplayRenderedCommandMenu(VariableRowIndex(row_index)),
&self.menus.rendered_command_menu,
Icon::Terminal,
),
_ => {
log::warn!("Secret type not supported for button rendering");
return Empty::new().finish();
}
};
let text_and_icon = TextAndIcon::new(
TextAndIconAlignment::IconFirst,
display_name,
icon.to_warpui_icon(appearance.theme().active_ui_text_color()),
MainAxisSize::Max,
MainAxisAlignment::Center,
vec2f(16., 16.),
)
.with_inner_padding(4.);
let default_button_styles = UiComponentStyles {
font_size: Some(appearance.ui_font_size()),
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
),
border_width: Some(1.),
border_color: Some(appearance.theme().surface_2().into()),
font_weight: Some(Weight::Bold),
background: Some(Fill::None),
..Default::default()
};
let hovered_styles = UiComponentStyles {
border_width: Some(1.),
border_color: Some(appearance.theme().accent_button_color().into()),
..default_button_styles
};
let mut button = appearance
.ui_builder()
.button(ButtonVariant::Outlined, menu_button_mouse_state)
.with_style(default_button_styles)
.with_hovered_styles(hovered_styles)
.with_text_and_icon_label(text_and_icon);
if FeatureFlag::SharedWithMe.is_enabled() && !editability.can_edit() {
button = button.disabled();
}
let button = button
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()));
Shrinkable::new(
1.,
Container::new(
ConstrainedBox::new({
let mut stack = Stack::new().with_child(Clipped::new(button.finish()).finish());
if is_focused {
stack.add_positioned_overlay_child(
ChildView::new(menu).finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopLeft,
),
);
}
stack.finish()
})
.with_width(CORE_MAX_WIDTH)
.finish(),
)
.with_margin_right(ROW_SPACING)
.finish(),
)
.finish()
}
}
@@ -0,0 +1,80 @@
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{Container, MouseStateHandle},
fonts::Weight,
platform::Cursor,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
Element,
};
use crate::ui_components::dialog::{dialog_styles, Dialog};
use super::env_var_collection::{EnvVarCollectionAction, EnvVarCollectionView};
const UNSAVED_CHANGES_TEXT: &str = "You have unsaved changes.";
const KEEP_EDITING_TEXT: &str = "Keep editing";
const DISCARD_CHANGES_TEXT: &str = "Discard changes";
const BUTTON_FONT_SIZE: f32 = 14.;
const BUTTON_PADDING: f32 = 12.;
const MODAL_HORIZONTAL_MARGIN: f32 = 28.;
const DIALOG_WIDTH: f32 = 460.;
impl EnvVarCollectionView {
pub fn render_unsaved_changes_dialog_button(
&self,
appearance: &Appearance,
button_mouse_state: MouseStateHandle,
action: EnvVarCollectionAction,
text: &str,
) -> Box<dyn Element> {
appearance
.ui_builder()
.button(ButtonVariant::Secondary, button_mouse_state)
.with_style(UiComponentStyles {
font_size: Some(BUTTON_FONT_SIZE),
font_weight: Some(Weight::Bold),
padding: Some(Coords::uniform(BUTTON_PADDING)),
..Default::default()
})
.with_text_label(text.into())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
.finish()
}
pub fn render_unsaved_changes_dialog(&self, appearance: &Appearance) -> Box<dyn Element> {
let keep_editing_button = self.render_unsaved_changes_dialog_button(
appearance,
self.button_mouse_states.keep_editing_state.clone(),
EnvVarCollectionAction::CloseUnsavedChangesDialog,
KEEP_EDITING_TEXT,
);
let discard_changes_button = self.render_unsaved_changes_dialog_button(
appearance,
self.button_mouse_states.discard_changes_state.clone(),
EnvVarCollectionAction::ForceClose,
DISCARD_CHANGES_TEXT,
);
Container::new(
Dialog::new(
UNSAVED_CHANGES_TEXT.to_string(),
None,
dialog_styles(appearance),
)
.with_bottom_row_child(keep_editing_button)
.with_bottom_row_child(discard_changes_button)
.with_width(DIALOG_WIDTH)
.build()
.finish(),
)
.with_margin_left(MODAL_HORIZONTAL_MARGIN)
.with_margin_right(MODAL_HORIZONTAL_MARGIN)
.finish()
}
}