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