Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,225 @@
use warpui::{
elements::{CornerRadius, Dismiss, MouseStateHandle, Radius},
fonts::Weight,
platform::Cursor,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::{
appearance::Appearance,
ui_components::{
blended_colors,
dialog::{dialog_styles, Dialog},
},
};
const BUTTON_PADDING: f32 = 12.;
const BUTTON_FONT_SIZE: f32 = 14.;
const BUTTON_BORDER_RADIUS: f32 = 4.;
const BORDER_WIDTH: f32 = 1.;
const DIALOG_WIDTH: f32 = 450.;
const CANCEL_TEXT: &str = "Cancel";
const DELETE_TEAM_TITLE_TEXT: &str = "Are you sure you want to delete this team?";
const LEAVE_TEAM_TITLE_TEXT: &str = "Are you sure you want to leave this team?";
const DELETE_TEAM_BODY_TEXT: &str = "Deleting this team will permanently delete it and all of its related content, including billing information or credits. You will not be able to restore them.";
const LEAVE_TEAM_BODY_TEXT: &str = "You will need to be reinvited in order to rejoin.";
const DELETE_TEAM_CONFIRM_TEXT: &str = "Yes, delete";
const LEAVE_TEAM_CONFIRM_TEXT: &str = "Yes, leave";
pub enum CloudActionConfirmationDialogEvent {
Cancel,
Confirm,
}
#[derive(Debug)]
pub enum CloudActionConfirmationDialogAction {
Cancel,
Confirm,
}
#[derive(Default)]
pub enum CloudActionConfirmationDialogVariant {
LeaveTeam,
DeleteTeam,
#[default]
None,
}
pub struct CloudActionConfirmationDialog {
cancel_mouse_state: MouseStateHandle,
confirm_mouse_state: MouseStateHandle,
variant: CloudActionConfirmationDialogVariant,
confirmation_button_enabled: bool,
}
impl CloudActionConfirmationDialog {
pub fn new() -> Self {
Self {
cancel_mouse_state: Default::default(),
confirm_mouse_state: Default::default(),
variant: Default::default(),
confirmation_button_enabled: true,
}
}
pub fn set_variant(&mut self, variant: CloudActionConfirmationDialogVariant) {
self.variant = variant;
}
pub fn set_confirmation_button_enabled(&mut self, enabled: bool) {
self.confirmation_button_enabled = enabled;
}
fn title_text(&self) -> String {
match self.variant {
CloudActionConfirmationDialogVariant::LeaveTeam => LEAVE_TEAM_TITLE_TEXT.to_string(),
CloudActionConfirmationDialogVariant::DeleteTeam => DELETE_TEAM_TITLE_TEXT.to_string(),
CloudActionConfirmationDialogVariant::None => "".to_string(),
}
}
fn body_text(&self) -> String {
match self.variant {
CloudActionConfirmationDialogVariant::LeaveTeam => LEAVE_TEAM_BODY_TEXT.to_string(),
CloudActionConfirmationDialogVariant::DeleteTeam => DELETE_TEAM_BODY_TEXT.to_string(),
CloudActionConfirmationDialogVariant::None => "".to_string(),
}
}
fn confirm_button_text(&self) -> String {
match self.variant {
CloudActionConfirmationDialogVariant::LeaveTeam => LEAVE_TEAM_CONFIRM_TEXT.to_string(),
CloudActionConfirmationDialogVariant::DeleteTeam => {
DELETE_TEAM_CONFIRM_TEXT.to_string()
}
CloudActionConfirmationDialogVariant::None => "".to_string(),
}
}
}
impl Entity for CloudActionConfirmationDialog {
type Event = CloudActionConfirmationDialogEvent;
}
impl View for CloudActionConfirmationDialog {
fn ui_name() -> &'static str {
"CloudActionConfirmationDialog"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let default_button_styles = UiComponentStyles {
font_size: Some(BUTTON_FONT_SIZE),
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
),
font_weight: Some(Weight::Bold),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(BUTTON_BORDER_RADIUS))),
border_color: Some(appearance.theme().outline().into()),
border_width: Some(BORDER_WIDTH),
padding: Some(Coords::uniform(BUTTON_PADDING)),
background: Some(appearance.theme().surface_1().into()),
..Default::default()
};
let primary_button_styles = UiComponentStyles {
background: Some(appearance.theme().accent_button_color().into()),
border_color: Some(appearance.theme().accent_button_color().into()),
..default_button_styles
};
let primary_hovered_and_clicked_styles = UiComponentStyles {
background: Some(blended_colors::accent_hover(appearance.theme()).into()),
border_color: Some(blended_colors::accent_hover(appearance.theme()).into()),
..primary_button_styles
};
let cancel_button = appearance
.ui_builder()
.button(ButtonVariant::Secondary, self.cancel_mouse_state.clone())
.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(CANCEL_TEXT.into())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(CloudActionConfirmationDialogAction::Cancel)
})
.finish();
let confirm_hoverable = appearance
.ui_builder()
.button_with_custom_styles(
ButtonVariant::Basic,
self.confirm_mouse_state.clone(),
primary_button_styles,
Some(primary_hovered_and_clicked_styles),
Some(primary_hovered_and_clicked_styles),
Some(primary_hovered_and_clicked_styles),
)
.with_text_label(self.confirm_button_text())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(CloudActionConfirmationDialogAction::Confirm)
});
let confirm_button = if self.confirmation_button_enabled {
confirm_hoverable.finish()
} else {
confirm_hoverable.disable().finish()
};
let dialog = Dialog::new(
self.title_text(),
Some(self.body_text()),
dialog_styles(appearance),
)
.with_bottom_row_child(cancel_button)
.with_bottom_row_child(confirm_button)
.with_width(DIALOG_WIDTH)
.build()
.finish();
Dismiss::new(dialog)
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(CloudActionConfirmationDialogAction::Cancel)
})
.finish()
}
}
impl TypedActionView for CloudActionConfirmationDialog {
type Action = CloudActionConfirmationDialogAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
CloudActionConfirmationDialogAction::Cancel => {
ctx.emit(CloudActionConfirmationDialogEvent::Cancel)
}
CloudActionConfirmationDialogAction::Confirm => {
self.set_confirmation_button_enabled(false);
ctx.notify();
ctx.emit(CloudActionConfirmationDialogEvent::Confirm)
}
}
}
}
+333
View File
@@ -0,0 +1,333 @@
use warpui::{
elements::{
Border, Clipped, Container, CornerRadius, Dismiss, Empty, Flex, MainAxisSize,
MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
},
fonts::Weight,
platform::Cursor,
presenter::ChildView,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, ViewHandle,
};
use crate::cloud_object::Space;
use crate::{
appearance::Appearance, editor::EditorView, server::ids::SyncId, ui_components::blended_colors,
};
use super::{index::DriveIndexAction, DriveObjectType};
const DIALOG_PADDING: f32 = 24.;
const INPUT_MARGIN_TOP: f32 = 16.;
const INPUT_MARGIN_BOTTOM: f32 = 24.;
const INPUT_PADDING_HORIZONTAL: f32 = 16.;
const INPUT_PADDING_VERTICAL: f32 = 10.;
const BORDER_RADIUS_SMALL: f32 = 4.;
const BORDER_RADIUS_LARGE: f32 = 8.;
const BORDER_WIDTH: f32 = 1.;
const BUTTON_FONT_SIZE: f32 = 14.;
const BUTTON_PADDING: f32 = 12.;
const BUTTON_MARGIN_BETWEEN: f32 = 8.;
const NOTEBOOK_TITLE: &str = "Notebook name";
const FOLDER_TITLE: &str = "Folder name";
const ENV_VAR_COLLECTION_TITLE: &str = "Collection name";
const CREATE_BUTTON_TEXT: &str = "Create";
const CANCEL_BUTTON_TEXT: &str = "Cancel";
const RENAME_BUTTON_TEXT: &str = "Rename";
/// Struct holding necessary infromation and states for the dialog
/// that opens when creating or updating a folder or notebook.
///
/// This dialog can be opened for a folder or a space. If open_for_folder_id = None, it's a space.
/// If open_for_folder_id = Some, it's a specific folder.
#[derive(Clone)]
pub struct CloudObjectNamingDialog {
pub title_editor: ViewHandle<EditorView>,
cancel_mouse_state: MouseStateHandle,
primary_action_mouse_state: MouseStateHandle,
pub object_type: Option<DriveObjectType>,
pub space: Option<Space>,
is_rename: bool,
// If the naming dialog is opened for a folder, then we store the open_for_folder_id.
pub open_for_folder_id: Option<SyncId>,
}
impl CloudObjectNamingDialog {
pub fn new(title_editor: ViewHandle<EditorView>) -> Self {
Self {
title_editor,
cancel_mouse_state: Default::default(),
primary_action_mouse_state: Default::default(),
object_type: Default::default(),
space: None,
is_rename: false,
open_for_folder_id: None,
}
}
pub fn close(&mut self, app: &mut AppContext) {
self.object_type = None;
self.space = None;
self.is_rename = false;
self.open_for_folder_id = None;
self.title_editor.update(app, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
ctx.notify();
});
}
pub fn open(
&mut self,
object_type: DriveObjectType,
space: Space,
initial_folder_id: Option<SyncId>,
is_rename: bool,
existing_name: Option<String>,
app: &mut AppContext,
) {
self.object_type = Some(object_type);
self.space = Some(space);
self.is_rename = is_rename;
self.open_for_folder_id = initial_folder_id;
if let Some(name) = existing_name {
self.title_editor.update(app, |editor, ctx| {
editor.set_buffer_text(name.as_str(), ctx)
})
}
}
pub fn is_open(&self) -> bool {
self.object_type.is_some()
}
// The renaming dialog can either be open for a space or a folder. If it's a space, open_for_folder_id = None.
pub fn is_open_for_space(&self, space: &Space) -> bool {
self.is_open() && self.open_for_folder_id.is_none() && (self.space == Some(*space))
}
pub fn is_open_for_folder(&self, folder_id: SyncId) -> bool {
self.is_open() && (self.open_for_folder_id == Some(folder_id))
}
/// Returns the KnowledgeIndexAction that's appropriate to the current state of this dialog.
/// If the dialog is not open or in an invalid state, returns None.
pub fn current_primary_action(&self) -> Option<DriveIndexAction> {
match self.open_for_folder_id {
Some(folder_id) if self.is_rename => Some(DriveIndexAction::RenameFolder { folder_id }),
_ => {
let object_type = self.object_type?;
let space = self.space?;
Some(DriveIndexAction::CreateObject {
object_type,
space,
initial_folder_id: self.open_for_folder_id,
})
}
}
}
pub fn title(&self, app: &AppContext) -> Option<String> {
self.is_open()
.then(|| self.title_editor.as_ref(app).buffer_text(app))
}
fn render_text_header(
&self,
object_type: DriveObjectType,
appearance: &Appearance,
) -> Box<dyn Element> {
let title = match object_type {
DriveObjectType::Notebook { .. } => NOTEBOOK_TITLE,
DriveObjectType::Folder => FOLDER_TITLE,
DriveObjectType::EnvVarCollection => ENV_VAR_COLLECTION_TITLE,
// workflows and ai facts aren't a part of this dialog
DriveObjectType::Workflow
| DriveObjectType::AgentModeWorkflow
| DriveObjectType::AIFact
| DriveObjectType::AIFactCollection
| DriveObjectType::MCPServer
| DriveObjectType::MCPServerCollection => "",
};
Text::new_inline(
title,
appearance.ui_font_family(),
appearance.header_font_size(),
)
.with_color(
appearance
.theme()
.main_text_color(appearance.theme().surface_1())
.into(),
)
.finish()
}
fn render_input(&self, appearance: &Appearance) -> Box<dyn Element> {
Container::new(Clipped::new(ChildView::new(&self.title_editor).finish()).finish())
.with_margin_top(INPUT_MARGIN_TOP)
.with_margin_bottom(INPUT_MARGIN_BOTTOM)
.with_padding_top(INPUT_PADDING_VERTICAL)
.with_padding_bottom(INPUT_PADDING_VERTICAL)
.with_padding_left(INPUT_PADDING_HORIZONTAL)
.with_padding_right(INPUT_PADDING_HORIZONTAL)
.with_background(appearance.theme().background())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(BORDER_RADIUS_SMALL)))
.with_border(Border::all(BORDER_WIDTH).with_border_fill(appearance.theme().outline()))
.finish()
}
fn render_action_buttons(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let default_button_styles = UiComponentStyles {
font_size: Some(BUTTON_FONT_SIZE),
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.main_text_color(appearance.theme().accent_button_color())
.into(),
),
font_weight: Some(Weight::Bold),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(BORDER_RADIUS_SMALL))),
border_color: Some(appearance.theme().outline().into()),
border_width: Some(BORDER_WIDTH),
padding: Some(Coords::uniform(BUTTON_PADDING)),
background: Some(appearance.theme().surface_1().into()),
..Default::default()
};
let primary_button_styles = UiComponentStyles {
background: Some(appearance.theme().accent_button_color().into()),
border_color: Some(appearance.theme().accent_button_color().into()),
..default_button_styles
};
let primary_disabled_styles = UiComponentStyles {
background: Some(appearance.theme().surface_3().into()),
border_color: Some(appearance.theme().surface_3().into()),
font_color: Some(
appearance
.theme()
.disabled_text_color(appearance.theme().background())
.into(),
),
..primary_button_styles
};
let primary_hovered_and_clicked_styles = UiComponentStyles {
background: Some(blended_colors::accent_hover(appearance.theme()).into()),
border_color: Some(blended_colors::accent_hover(appearance.theme()).into()),
..primary_button_styles
};
let primary_button_text = match self.is_rename {
true => RENAME_BUTTON_TEXT,
false => CREATE_BUTTON_TEXT,
};
let primary_button_action = self.current_primary_action();
let mut primary_button = appearance
.ui_builder()
.button_with_custom_styles(
ButtonVariant::Basic,
self.primary_action_mouse_state.clone(),
primary_button_styles,
Some(primary_hovered_and_clicked_styles),
Some(primary_hovered_and_clicked_styles),
Some(primary_disabled_styles),
)
.with_text_label(primary_button_text.into());
if let Some(title) = self.title(app) {
if title.is_empty() || !self.title_editor.as_ref(app).is_dirty(app) {
primary_button = primary_button.disabled();
}
}
Flex::row()
.with_child(
Shrinkable::new(
1.,
Container::new(
appearance
.ui_builder()
.button(ButtonVariant::Secondary, self.cancel_mouse_state.clone())
.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(CANCEL_BUTTON_TEXT.into())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(
DriveIndexAction::CloseCloudObjectNamingDialog,
)
})
.finish(),
)
.with_margin_right(BUTTON_MARGIN_BETWEEN)
.finish(),
)
.finish(),
)
.with_child(
Shrinkable::new(
1.,
Container::new(
primary_button
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
if let Some(primary_action) = primary_button_action.clone() {
ctx.dispatch_typed_action(primary_action)
}
})
.finish(),
)
.finish(),
)
.finish(),
)
.with_main_axis_size(MainAxisSize::Max)
.finish()
}
pub fn render(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let object_type = self.object_type.unwrap_or(DriveObjectType::Folder);
if self.space.is_none() {
return Empty::new().finish();
}
let theme = appearance.theme();
Dismiss::new(
Container::new(
Flex::column()
.with_child(self.render_text_header(object_type, appearance))
.with_child(self.render_input(appearance))
.with_child(self.render_action_buttons(appearance, app))
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(BORDER_RADIUS_LARGE)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_background(theme.surface_1())
.with_uniform_padding(DIALOG_PADDING)
.finish(),
)
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(DriveIndexAction::CloseCloudObjectNamingDialog)
})
.finish()
}
}
+56
View File
@@ -0,0 +1,56 @@
use pathfinder_color::ColorU;
use warp_core::ui::{
appearance::Appearance,
color::{contrast::MinimumAllowedContrast, ContrastingColor},
theme::Fill,
};
use super::DriveObjectType;
use crate::ui_components::blended_colors;
pub fn warp_drive_icon_color(
appearance: &Appearance,
cloud_object_type: DriveObjectType,
) -> ColorU {
match cloud_object_type {
DriveObjectType::Workflow => {
let color: Fill = appearance.theme().terminal_colors().normal.red.into();
color
.on_background(
appearance.theme().surface_1(),
MinimumAllowedContrast::NonText,
)
.into()
}
DriveObjectType::Notebook { .. } => {
let color: Fill = appearance.theme().terminal_colors().normal.blue.into();
color
.on_background(
appearance.theme().surface_1(),
MinimumAllowedContrast::NonText,
)
.into()
}
DriveObjectType::EnvVarCollection => {
let color: Fill = appearance.theme().terminal_colors().normal.magenta.into();
color
.on_background(
appearance.theme().surface_1(),
MinimumAllowedContrast::NonText,
)
.into()
}
DriveObjectType::Folder => {
// Match File Tree styling - use text_sub color
blended_colors::text_sub(appearance.theme(), appearance.theme().background())
}
DriveObjectType::AIFactCollection
| DriveObjectType::AIFact
| DriveObjectType::MCPServer
| DriveObjectType::MCPServerCollection
| DriveObjectType::AgentModeWorkflow => appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
}
}
+83
View File
@@ -0,0 +1,83 @@
use warpui::{SingletonEntity, ViewContext};
use crate::{
auth::{auth_manager::AuthManager, AuthStateProvider},
cloud_object::{
model::persistence::CloudModel, GenericStringObjectFormat, JsonObjectType, ObjectType,
Space,
},
};
pub fn has_feature_gated_anonymous_user_reached_notebook_limit<V: warpui::View>(
ctx: &mut ViewContext<V>,
) -> bool {
let count = CloudModel::handle(ctx).read(ctx, |model, ctx| {
model
.active_non_welcome_notebooks_in_space(Space::Personal, ctx)
.count()
});
if AuthStateProvider::handle(ctx).read(ctx, |auth_state_provider, _ctx| {
auth_state_provider
.get()
.is_anonymous_user_past_object_limit(ObjectType::Notebook, count + 1)
.unwrap_or_default()
}) {
AuthManager::handle(ctx).update(ctx, |auth_manager: &mut AuthManager, ctx| {
auth_manager.anonymous_user_hit_drive_object_limit(ctx);
});
return true;
};
false
}
pub fn has_feature_gated_anonymous_user_reached_workflow_limit<V: warpui::View>(
ctx: &mut ViewContext<V>,
) -> bool {
let count = CloudModel::handle(ctx).read(ctx, |model, ctx| {
model
.active_non_welcome_workflows_in_space(Space::Personal, ctx)
.count()
});
if AuthStateProvider::handle(ctx).read(ctx, |auth_state_provider, _ctx| {
auth_state_provider
.get()
.is_anonymous_user_past_object_limit(ObjectType::Workflow, count + 1)
.unwrap_or_default()
}) {
AuthManager::handle(ctx).update(ctx, |auth_manager: &mut AuthManager, ctx| {
auth_manager.anonymous_user_hit_drive_object_limit(ctx);
});
return true;
};
false
}
pub fn has_feature_gated_anonymous_user_reached_env_var_limit<V: warpui::View>(
ctx: &mut ViewContext<V>,
) -> bool {
let count = CloudModel::handle(ctx).read(ctx, |model, ctx| {
model
.active_non_welcome_env_var_collections_in_space(Space::Personal, ctx)
.count()
});
if AuthStateProvider::handle(ctx).read(ctx, |auth_state_provider, _ctx| {
auth_state_provider
.get()
.is_anonymous_user_past_object_limit(
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
JsonObjectType::EnvVarCollection,
)),
count + 1,
)
.unwrap_or_default()
}) {
AuthManager::handle(ctx).update(ctx, |auth_manager: &mut AuthManager, ctx| {
auth_manager.anonymous_user_hit_drive_object_limit(ctx);
});
return true;
};
false
}
@@ -0,0 +1,120 @@
use warpui::{
elements::MouseStateHandle,
fonts::Weight,
platform::Cursor,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::{
appearance::Appearance,
ui_components::dialog::{dialog_styles, Dialog},
};
const CANCEL_TEXT: &str = "Cancel";
const EMPTY_TRASH_TITLE_TEXT: &str = "Are you sure you want to empty the trash?";
const EMPTY_TRASH_BODY_TEXT: &str = "This action cannot be undone.";
const EMPTY_TRASH_CONFIRM_TEXT: &str = "Yes, empty trash";
// This follows our new design standard for confirmation dialogs (e.g. used in the session sharing dialog)
// Design team has discouraged us from continuing to use CloudActionConfirmationDialog's current design
// TODO: update CloudActionConfirmationDialog to use this design
pub enum EmptyTrashConfirmationEvent {
Confirm,
Cancel,
}
#[derive(Debug)]
pub enum EmptyTrashConfirmationAction {
Confirm,
Cancel,
}
pub struct EmptyTrashConfirmationDialog {
cancel_mouse_state: MouseStateHandle,
confirm_mouse_state: MouseStateHandle,
}
impl EmptyTrashConfirmationDialog {
pub fn new() -> Self {
Self {
cancel_mouse_state: Default::default(),
confirm_mouse_state: Default::default(),
}
}
}
impl Entity for EmptyTrashConfirmationDialog {
type Event = EmptyTrashConfirmationEvent;
}
impl View for EmptyTrashConfirmationDialog {
fn ui_name() -> &'static str {
"EmptyTrashConfirmationDialog"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let button_style = UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Bold),
width: Some(202.),
height: Some(40.),
..Default::default()
};
let confirm_button = appearance
.ui_builder()
.button(ButtonVariant::Accent, self.confirm_mouse_state.clone())
.with_centered_text_label(EMPTY_TRASH_CONFIRM_TEXT.into())
.with_style(button_style)
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EmptyTrashConfirmationAction::Confirm)
})
.finish();
let cancel_button = appearance
.ui_builder()
.button(ButtonVariant::Basic, self.cancel_mouse_state.clone())
.with_centered_text_label(CANCEL_TEXT.into())
.with_style(button_style)
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EmptyTrashConfirmationAction::Cancel)
})
.finish();
Dialog::new(
EMPTY_TRASH_TITLE_TEXT.into(),
Some(EMPTY_TRASH_BODY_TEXT.into()),
UiComponentStyles {
width: Some(460.),
padding: Some(Coords::uniform(24.)),
..dialog_styles(appearance)
},
)
.with_bottom_row_child(cancel_button)
.with_bottom_row_child(confirm_button)
.build()
.finish()
}
}
impl TypedActionView for EmptyTrashConfirmationDialog {
type Action = EmptyTrashConfirmationAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
EmptyTrashConfirmationAction::Confirm => ctx.emit(EmptyTrashConfirmationEvent::Confirm),
EmptyTrashConfirmationAction::Cancel => ctx.emit(EmptyTrashConfirmationEvent::Cancel),
}
}
}
+557
View File
@@ -0,0 +1,557 @@
#[cfg(feature = "local_fs")]
use std::io::ErrorKind;
use std::{
collections::{
hash_map::{Entry, OccupiedEntry},
HashMap,
},
path::{Path, PathBuf},
};
#[cfg(feature = "local_fs")]
use aho_corasick::{AhoCorasick, MatchKind};
#[cfg(feature = "local_fs")]
use anyhow::{anyhow, Context};
#[cfg(feature = "local_fs")]
use futures::AsyncWriteExt;
use warp_util::path::ShellFamily;
use warpui::{
platform::{file_picker::FilePickerError, FilePickerConfiguration, OperatingSystem},
r#async::SpawnedFutureHandle,
AppContext, Entity, ModelContext, SingletonEntity, WindowId,
};
use crate::{
cloud_object::{model::persistence::CloudModel, Space},
safe_warn,
view_components::DismissibleToast,
workspace::{active_terminal_in_window, ToastStack},
};
#[cfg(feature = "local_fs")]
use crate::{
notebooks::export_notebook, server::cloud_objects::update_manager::get_duplicate_object_name,
view_components::ToastLink, workflows::export_workflow::export_serialize,
workspace::WorkspaceAction,
};
use super::CloudObjectTypeAndId;
/// Singleton model for exporting from Warp Drive.
pub struct ExportManager {
exports: HashMap<ExportId, Export>,
}
/// Identifier for an export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ExportId(CloudObjectTypeAndId, Space);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportEvent {
/// Export of this item was canceled.
Canceled(ExportId),
/// Export of this item failed.
Failed {
/// The overall export ID.
id: ExportId,
},
/// Export completed.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
Completed { id: ExportId, path: PathBuf },
}
/// A single Warp Drive export.
struct Export {
/// The ID of the window that started this export, for showing toasts.
window_id: WindowId,
state: State,
// Whether this is a bulk export.
is_bulk: bool,
}
enum State {
/// The user is picking where to export to.
ChoosingLocation,
Exporting(SpawnedFutureHandle),
}
/// # Flow
/// The overall flow for export is asynchronous, and requires user input at the beginning.
/// 1. An entrypoint calls [`ExportManager::export`] to start a new export.
/// This initializes some state and opens a file picker.
/// 2. The user chooses a directory or cancels, calling [`ExportManager::handle_files_picked`].
/// If they canceled, the export ends. Otherwise [`ExportManager::run_export`] begins exporting
/// individual objects.
/// 3. Each object to export is processed by [`ExportManager::export_one`], which serializes the
/// object and then asynchronously writes it to disk using [`write_object`].
/// 4. Once writing an object finishes, the result is handled by
/// [`ExportManager::handle_object_export`]. If the export is done, it emits an
/// [`ExportEvent::Completed`] event. If it failed, it emits an [`ExportEvent::Failed`].
impl ExportManager {
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
Self {
exports: Default::default(),
}
}
/// Export a list of objects.
pub fn export(
&mut self,
window_id: WindowId,
objects: &[CloudObjectTypeAndId],
ctx: &mut ModelContext<Self>,
) {
let shell_family =
active_terminal_in_window(window_id, ctx, |terminal, ctx| terminal.shell_family(ctx))
.unwrap_or_else(|| OperatingSystem::get().default_shell_family());
let is_bulk = objects.len() > 1;
let mut ids = Vec::new();
for object in objects {
match CloudModel::as_ref(ctx).get_by_uid(&object.uid()) {
None => log::warn!("Tried to export unknown object {object:?}"),
Some(obj) if !obj.can_export() => {
log::warn!("Tried to export un-exportable object {object:?}")
}
Some(cloud_object) => {
let id = ExportId(*object, cloud_object.space(ctx));
ids.push(id);
match self.exports.entry(id) {
Entry::Occupied(_) => {
log::info!("Object {object:?} is already being exported")
}
Entry::Vacant(entry) => {
entry.insert(Export::new(window_id, is_bulk));
}
}
}
}
}
ctx.open_file_picker(
move |result, app| {
Self::handle(app).update(app, |me, ctx| {
me.handle_files_picked(ids, result, shell_family, ctx);
});
},
FilePickerConfiguration::new().folders_only(),
);
}
/// Handle the file picker selection.
fn handle_files_picked(
&mut self,
ids: Vec<ExportId>,
result: Result<Vec<String>, FilePickerError>,
shell_family: ShellFamily,
ctx: &mut ModelContext<Self>,
) {
match result {
Ok(mut paths) => {
match paths.pop() {
Some(path) => {
let path = PathBuf::from(path);
for id in ids {
self.run_export(id, &path, shell_family, ctx);
}
}
None => {
// User cancelled
for id in ids {
self.cancel(id, ctx);
}
}
}
}
Err(err) => {
if let Some(export) = ids.first().and_then(|id| self.exports.get(id)) {
let window_id = export.window_id;
ToastStack::handle(ctx).update(ctx, move |toast_stack, ctx| {
let toast = DismissibleToast::error(format!("{err}"));
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
for id in ids {
self.cancel(id, ctx);
}
}
}
}
/// Begin exporting into the given directory.
fn run_export(
&mut self,
id: ExportId,
path: &Path,
shell_family: ShellFamily,
ctx: &mut ModelContext<Self>,
) {
match self.exports.entry(id) {
Entry::Occupied(mut export) => match export.get().state {
State::ChoosingLocation => {
log::debug!("Exporting {id:?} to {}", path.display());
match Self::export_one(id, export.get().is_bulk, path, id.0, shell_family, ctx)
{
Ok(handle) => {
export.get_mut().state = State::Exporting(handle);
}
Err(ref err) => Self::handle_failure(export, err, ctx),
}
}
State::Exporting(_) => {
log::warn!("Tried to restart in-progress export of {id:?}");
}
},
Entry::Vacant(_) => {
log::warn!("Tried to start unknown export for {id:?}");
}
}
}
/// Handle an object's export finishing.
#[cfg(feature = "local_fs")]
fn handle_object_export(
&mut self,
id: ExportId,
object: CloudObjectTypeAndId,
path: anyhow::Result<PathBuf>,
ctx: &mut ModelContext<Self>,
) {
let (is_bulk, window_id) = match self.exports.entry(id) {
Entry::Occupied(export) => match path {
Ok(ref path) => {
let (is_bulk, window_id) = (export.get().is_bulk, export.get().window_id);
// TODO: Will need queue for folders.
log::debug!("Exported {object:?} to {} successfully", path.display());
Self::handle_completion(export, path.clone(), ctx);
(is_bulk, window_id)
}
Err(ref err) => {
let (is_bulk, window_id) = (export.get().is_bulk, export.get().window_id);
Self::handle_failure(export, err, ctx);
(is_bulk, window_id)
}
},
Entry::Vacant(_) => {
log::warn!("Received update for unknown export {id:?}");
return;
}
};
if is_bulk && self.exports.is_empty() {
ToastStack::handle(ctx).update(ctx, move |toast_stack, ctx| {
let link_label = if cfg!(target_os = "macos") {
"Open in Finder"
} else {
"Open in folder"
};
let mut toast_link = ToastLink::new(link_label.to_string());
if let Ok(path) = path {
// The path to open in the bulk case is one level up from the export dir.
let root_dir = path.parent().unwrap_or(path.as_path()).to_path_buf();
toast_link = toast_link
.with_onclick_action(WorkspaceAction::OpenInExplorer { path: root_dir });
}
toast_stack.add_ephemeral_toast(
DismissibleToast::success("Finished exporting objects".to_string())
.with_link(toast_link),
window_id,
ctx,
);
});
}
}
/// Drive export of a single object.
#[cfg(feature = "local_fs")]
fn export_one(
id: ExportId,
is_bulk: bool,
parent_path: &Path,
object: CloudObjectTypeAndId,
shell_family: ShellFamily,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<SpawnedFutureHandle> {
let cloud_model = CloudModel::as_ref(ctx);
let (name, extension, data) = match object {
CloudObjectTypeAndId::Workflow(workflow_id) => {
let workflow = cloud_model
.get_workflow(&workflow_id)
.ok_or_else(|| anyhow!("no workflow for {workflow_id}"))?;
let mut serializer = serde_yaml::Serializer::new(Vec::new());
export_serialize(&workflow.model().data, &mut serializer, ctx)?;
let data = serializer.into_inner();
(workflow.model().data.name().to_owned(), "yaml", data)
}
CloudObjectTypeAndId::Notebook(notebook_id) => {
let notebook = cloud_model
.get_notebook(&notebook_id)
.ok_or_else(|| anyhow!("no notebook for {notebook_id}"))?;
let internal_data = &notebook.model().data;
// If we're unable to translate the Markdown for export, fall back to the original
// text.
let data = export_notebook(internal_data, ctx)
.unwrap_or_else(|_| internal_data.clone())
.into_bytes();
(notebook.model().title.clone(), "md", data)
}
CloudObjectTypeAndId::GenericStringObject { object_type, id } => {
if let Some(env_var_collection) = cloud_model.get_env_var_collection(&id) {
let env_var_collection_model = env_var_collection.model();
let exported_variables = env_var_collection_model
.string_model
.export_variables("\n", shell_family)
.into_bytes();
(
env_var_collection_model
.string_model
.title
.clone()
.unwrap_or_default(),
"env",
exported_variables,
)
} else {
anyhow::bail!("exporting {object_type:?} not yet supported")
}
}
other => {
anyhow::bail!("exporting {other:?} not yet supported")
}
};
let name = if name.is_empty() {
"Untitled".to_string()
} else {
safe_filename(&name)
};
let path = if is_bulk {
parent_path.join(safe_filename(&id.1.name(ctx)))
} else {
parent_path.to_path_buf()
};
Ok(ctx.spawn(
async move { write_object(path, is_bulk, name, extension, data).await },
move |me, result, ctx| {
me.handle_object_export(id, object, result, ctx);
},
))
}
#[cfg(not(feature = "local_fs"))]
fn export_one(
_id: ExportId,
_is_bulk: bool,
_parent_path: &Path,
_object: CloudObjectTypeAndId,
_shell_family: ShellFamily,
_ctx: &mut ModelContext<Self>,
) -> anyhow::Result<SpawnedFutureHandle> {
anyhow::bail!("export not supported without a local filesystem")
}
/// Cancel an export.
fn cancel(&mut self, id: ExportId, ctx: &mut ModelContext<Self>) {
if self.exports.remove(&id).is_some() {
ctx.emit(ExportEvent::Canceled(id));
}
}
/// Handle an error exporting an object.
fn handle_failure(
export: OccupiedEntry<ExportId, Export>,
error: &anyhow::Error,
ctx: &mut ModelContext<Self>,
) {
let id = *export.key();
// Don't send the error to Sentry, since it likely includes a user file path and their Warp
// Drive object name. Also don't report this as an error, since the most likely failure
// reason is an I/O issue on the user's machine (like being out of disk space, or exporting
// to a directory they can't write to).
safe_warn!(
safe: ("Exporting {id:?} failed"),
full: ("Exporting {id:?} failed: {error:#}")
);
ctx.emit(ExportEvent::Failed { id: *export.key() });
let window_id = export.remove().window_id;
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let message = match id.display_name(ctx) {
Some(name) => format!("Failed to export {name}"),
None => "Export failed".to_string(),
};
toast_stack.add_persistent_toast(DismissibleToast::error(message), window_id, ctx);
});
}
/// Handle the last object in an export completing successfully.
#[cfg(feature = "local_fs")]
fn handle_completion(
export: OccupiedEntry<ExportId, Export>,
root_path: PathBuf,
ctx: &mut ModelContext<Self>,
) {
ctx.emit(ExportEvent::Completed {
id: *export.key(),
path: root_path.clone(),
});
if !export.get().is_bulk {
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let message = match export.key().display_name(ctx) {
Some(name) => format!("Exported {name}"),
None => "Exported object".to_string(),
};
let link_label = if cfg!(target_os = "macos") {
"Open in Finder"
} else {
"Open in folder"
};
toast_stack.add_ephemeral_toast(
DismissibleToast::success(message).with_link(
ToastLink::new(link_label.to_string()).with_onclick_action(
WorkspaceAction::OpenInExplorer { path: root_path },
),
),
export.get().window_id,
ctx,
);
});
}
export.remove();
}
}
impl Entity for ExportManager {
type Event = ExportEvent;
}
impl SingletonEntity for ExportManager {}
impl Export {
fn new(window_id: WindowId, is_bulk: bool) -> Self {
Self {
is_bulk,
state: State::ChoosingLocation,
window_id,
}
}
}
impl Drop for Export {
fn drop(&mut self) {
if let State::Exporting(handle) = &self.state {
handle.abort();
}
}
}
impl ExportId {
/// Display name for the root object being exported.
pub fn display_name(self, ctx: &AppContext) -> Option<String> {
CloudModel::as_ref(ctx)
.get_by_uid(&self.0.uid())
.map(|object| {
let mut name = object.display_name();
if name.is_empty() {
name.push_str("Untitled")
}
name
})
}
}
/// Write an object's exported representation to disk.
#[cfg(feature = "local_fs")]
async fn write_object(
parent_path: PathBuf,
is_bulk: bool,
object_name: String,
extension: &str,
object_data: Vec<u8>,
) -> anyhow::Result<PathBuf> {
use anyhow::bail;
if object_name.is_empty() {
// This should be handled in `export_one`, but do a final check here before writing
// anything to disk.
bail!("Cannot export unnamed object");
}
// Create the full path if it doesn't exist
if is_bulk {
async_fs::create_dir_all(&parent_path)
.await
.with_context(|| format!("could not create directory {}", parent_path.display()))?;
}
let mut current_name = object_name;
let mut open_options = async_fs::OpenOptions::new();
open_options.write(true).create_new(true);
loop {
let mut current_path = parent_path.join(&current_name);
current_path.set_extension(extension);
let file = open_options.open(&current_path).await;
match file {
Ok(mut file) => {
file.write_all(&object_data)
.await
.with_context(|| format!("could not export to {}", current_path.display()))?;
file.flush().await?;
return Ok(current_path);
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
current_name = get_duplicate_object_name(&current_name);
}
Err(err) => {
return Err(anyhow::Error::new(err)
.context(format!("could not create {}", current_path.display())))
}
}
}
}
#[cfg(feature = "local_fs")]
lazy_static::lazy_static! {
/// Matcher for characters which are forbidden in filenames.
static ref FORBIDDEN_FILENAME_PATTERNS: AhoCorasick = make_forbidden_filenames_matcher();
}
/// This is a helper for [`safe_filename`], which constructs a cached [`AhoCorasick`] matcher to
/// replace forbidden filename characters.
#[cfg(feature = "local_fs")]
fn make_forbidden_filenames_matcher() -> AhoCorasick {
// NTFS (Windows) disallows ASCII control characters in path names.
let ascii_control = 0x00..0x1f;
// These characters are disallowed by UNIX filesystems, APFS or HFS+ (macOS), or NTFS.
let forbidden = [b'/', b':', b'#', b'*', b'<', b'>', b'?', b'\\', b'|'];
let patterns = ascii_control.chain(forbidden).map(|ch| [ch]);
AhoCorasick::builder()
.match_kind(MatchKind::LeftmostFirst)
.build(patterns)
.expect("Path patterns should compile")
}
/// Replaces characters that are not allowed in a path name. This is _not_ escaping - disallowed
/// characters cannot be escaped in a path.
///
/// See [Comparison of filename limitations](https://en.wikipedia.org/wiki/Filename#Comparison_of_filename_limitations).
#[cfg(feature = "local_fs")]
pub fn safe_filename(filename: &str) -> String {
let mut result = String::new();
FORBIDDEN_FILENAME_PATTERNS.replace_all_with(filename, &mut result, |_, _, dst| {
// This replaces all forbidden characters with a `_`. We could use the match arguments to
// replace specific characters with something closer to their original semantics.
dst.push('_');
true
});
result
}
#[cfg(test)]
#[path = "export_tests.rs"]
mod tests;
+515
View File
@@ -0,0 +1,515 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
sync::Arc,
};
use futures::channel::oneshot;
use parking_lot::Mutex;
use tempfile::TempDir;
use warp_util::path::ShellFamily;
use warpui::{AddSingletonModel, App, SingletonEntity, WindowId};
use crate::{
cloud_object::{
model::persistence::CloudModel, CloudObjectMetadata, CloudObjectPermissions, ObjectIdType,
ObjectType, Space,
},
drive::CloudObjectTypeAndId,
notebooks::{CloudNotebook, CloudNotebookModel, NotebookId},
server::ids::SyncId,
workflows::{workflow::Workflow, CloudWorkflow, CloudWorkflowModel, WorkflowId},
workspace::ToastStack,
workspaces::user_workspaces::UserWorkspaces,
};
use super::{safe_filename, ExportEvent, ExportId, ExportManager};
struct ExportTest {
target_dir: TempDir,
pending_exports: Arc<Mutex<HashMap<ExportId, oneshot::Sender<ExportEvent>>>>,
}
impl ExportTest {
fn new(app: &mut App) -> Self {
let pending_exports = Arc::new(Mutex::new(
HashMap::<ExportId, oneshot::Sender<ExportEvent>>::new(),
));
{
let pending_exports = pending_exports.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&ExportManager::handle(ctx), move |_, event, _| {
let mut pending_exports = pending_exports.lock();
let id = match event {
ExportEvent::Canceled(id) => id,
ExportEvent::Failed { id, .. } => id,
ExportEvent::Completed { id, .. } => id,
};
if let Some(sender) = pending_exports.remove(id) {
let _ = sender.send(event.clone());
}
});
});
}
Self {
target_dir: TempDir::new().expect("failed to create temporary export directory"),
pending_exports,
}
}
/// Starts exporting an object into the temporary directory.
fn start_export(
&self,
export_ids: CloudObjectTypeAndId,
app: &mut App,
) -> (ExportId, oneshot::Receiver<ExportEvent>) {
let id = ExportId(export_ids, Space::Personal);
let (tx, rx) = oneshot::channel();
self.pending_exports.lock().insert(id, tx);
ExportManager::handle(app).update(app, |export_manager, ctx| {
let window_id = WindowId::new();
export_manager.export(window_id, &[export_ids], ctx);
export_manager.handle_files_picked(
vec![id],
Ok(vec![self
.target_dir
.path()
.to_str()
.expect("Path must be UTF-8")
.to_owned()]),
ShellFamily::Posix,
ctx,
);
id
});
(id, rx)
}
/// Get an export path, given the expected name.
fn path(&self, name: impl AsRef<Path>, space: Option<Space>, app: &App) -> PathBuf {
if let Some(space) = space {
let space_name = app.read(|ctx| space.name(ctx));
self.target_dir.path().join(space_name).join(name)
} else {
self.target_dir.path().join(name)
}
}
}
fn initialize_app(app: &mut App) {
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(ExportManager::new);
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| ToastStack);
}
/// Add a mocked workflow.
fn add_workflow(id: SyncId, workflow: Workflow, app: &mut App) {
CloudModel::handle(app).update(app, |cloud_model, _ctx| {
cloud_model.add_object(
id,
CloudWorkflow::new(
id,
CloudWorkflowModel::new(workflow),
CloudObjectMetadata::mock(),
CloudObjectPermissions::mock_personal(),
),
);
});
}
/// Add a mocked notebook.
fn add_notebook(id: SyncId, title: impl Into<String>, data: impl Into<String>, app: &mut App) {
CloudModel::handle(app).update(app, |cloud_model, _ctx| {
cloud_model.add_object(
id,
CloudNotebook::new(
id,
CloudNotebookModel {
title: title.into(),
data: data.into(),
ai_document_id: None,
conversation_id: None,
},
CloudObjectMetadata::mock(),
CloudObjectPermissions::mock_personal(),
),
);
});
}
#[test]
fn test_export_workflow_success() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let workflow_id = SyncId::ServerId(WorkflowId::from(123).into());
let workflow = Workflow::new("Test workflow", "echo hello world");
add_workflow(workflow_id, workflow, &mut app);
let exporter = ExportTest::new(&mut app);
let (id, export) = exporter.start_export(
CloudObjectTypeAndId::from_id_and_type(workflow_id, ObjectType::Workflow),
&mut app,
);
let expected_path = exporter.path("Test workflow.yaml", None, &app);
// The export should succeed.
assert_eq!(
export.await,
Ok(ExportEvent::Completed {
id,
path: expected_path.clone()
})
);
let contents =
fs::read_to_string(&expected_path).expect("failed to read exported workflow");
assert_eq!(
&contents,
r#"---
name: Test workflow
command: echo hello world
description: ~
arguments: []
tags: []
source_url: ~
author: ~
author_url: ~
shells: []
"#
);
});
}
#[test]
fn test_export_workflow_duplicate() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let workflow_id = SyncId::ServerId(WorkflowId::from(123).into());
let workflow = Workflow::new("Test workflow", "echo hello world");
add_workflow(workflow_id, workflow, &mut app);
let exporter = ExportTest::new(&mut app);
// Create a file at the default export path.
fs::write(
exporter.path("Test workflow.yaml", None, &app),
"Already exists",
)
.expect("failed to write existing workflow");
let (id, export) = exporter.start_export(
CloudObjectTypeAndId::from_id_and_type(workflow_id, ObjectType::Workflow),
&mut app,
);
let expected_path = exporter.path("Test workflow (1).yaml", None, &app);
// The export should succeed, and not overwrite the existing file.
assert_eq!(
export.await,
Ok(ExportEvent::Completed {
id,
path: expected_path.clone()
})
);
assert_eq!(
fs::read_to_string(exporter.path("Test workflow.yaml", None, &app))
.expect("failed to read original file"),
"Already exists"
);
let contents =
fs::read_to_string(&expected_path).expect("failed to read exported workflow");
assert_eq!(
&contents,
r#"---
name: Test workflow
command: echo hello world
description: ~
arguments: []
tags: []
source_url: ~
author: ~
author_url: ~
shells: []
"#
);
});
}
#[test]
fn test_export_workflow_failure() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let workflow_id = SyncId::ServerId(WorkflowId::from(123).into());
let workflow = Workflow::new("Test workflow", "echo hello world");
add_workflow(workflow_id, workflow, &mut app);
let exporter = ExportTest::new(&mut app);
// Ensure that the export will fail.
fs::remove_dir_all(exporter.target_dir.path()).expect("Could not remove test directory");
let (id, export) = exporter.start_export(
CloudObjectTypeAndId::from_id_and_type(workflow_id, ObjectType::Workflow),
&mut app,
);
// The export should error.
assert_eq!(export.await, Ok(ExportEvent::Failed { id }));
});
}
#[test]
fn test_export_notebook_with_embeds() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let workflow_id = SyncId::ServerId(WorkflowId::from(123).into());
let workflow = Workflow::new("Test workflow", "echo hello world");
add_workflow(workflow_id, workflow, &mut app);
let notebook_id = SyncId::ServerId(NotebookId::from(456).into());
add_notebook(
notebook_id,
"Test notebook",
format!(
r#"
# This is a notebook
It has *text*.
```warp-embedded-object
id: {}
```
This is code:
```Python
print("hello")
```
"#,
workflow_id.sqlite_uid_hash(ObjectIdType::Workflow)
),
&mut app,
);
let exporter = ExportTest::new(&mut app);
let (id, export) = exporter.start_export(
CloudObjectTypeAndId::from_id_and_type(notebook_id, ObjectType::Notebook),
&mut app,
);
let expected_path = exporter.path("Test notebook.md", None, &app);
// The export should succeed.
assert_eq!(
export.await,
Ok(ExportEvent::Completed {
id,
path: expected_path.clone()
})
);
let contents =
fs::read_to_string(&expected_path).expect("failed to read exported notebook");
assert_eq!(
contents,
r#"
# This is a notebook
It has *text*\.
```warp-embedded-object
---
name: Test workflow
command: echo hello world
tags: []
description: ~
arguments: []
source_url: ~
author: ~
author_url: ~
shells: []
environment_variables: ~
id: Workflow-test_uid00000000000123
```
This is code:
```python
print("hello")
```
"#
);
});
}
#[test]
fn test_export_untitled_notebook() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let notebook_id = SyncId::ServerId(NotebookId::from(456).into());
add_notebook(notebook_id, "", "This is untitled", &mut app);
let exporter = ExportTest::new(&mut app);
let (id, export) = exporter.start_export(
CloudObjectTypeAndId::from_id_and_type(notebook_id, ObjectType::Notebook),
&mut app,
);
let expected_path = exporter.path("Untitled.md", None, &app);
// The export should succeed.
assert_eq!(
export.await,
Ok(ExportEvent::Completed {
id,
path: expected_path.clone()
})
);
let contents =
fs::read_to_string(&expected_path).expect("failed to read exported notebook");
assert_eq!(&contents, "This is untitled");
});
}
#[test]
fn test_export_with_special_characters() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let workflow_id = SyncId::ServerId(WorkflowId::from(123).into());
let workflow = Workflow::new("Prefix: Some/workflow", "echo hello world");
add_workflow(workflow_id, workflow, &mut app);
let exporter = ExportTest::new(&mut app);
let (id, export) = exporter.start_export(
CloudObjectTypeAndId::from_id_and_type(workflow_id, ObjectType::Workflow),
&mut app,
);
let expected_path = exporter.path("Prefix_ Some_workflow.yaml", None, &app);
// The export should succeed, and transform the path.
assert_eq!(
export.await,
Ok(ExportEvent::Completed {
id,
path: expected_path.clone()
})
);
});
}
#[test]
fn test_safe_filename() {
for (expected_in, expected_out) in [
(
"allowed $special %characters",
"allowed $special %characters",
),
("warp:drive", "warp_drive"),
("a/b/c/d:e", "a_b_c_d_e"),
("the\0sneaky\0null", "the_sneaky_null"),
("ascii\x03control\x1bchars", "ascii_control_chars"),
] {
assert_eq!(safe_filename(expected_in), expected_out);
}
}
#[test]
fn test_export_multiple_objects() {
App::test((), |mut app| async move {
initialize_app(&mut app);
// Create two workflows and a notebook
let workflow_id1 = SyncId::ServerId(WorkflowId::from(123).into());
let workflow1 = Workflow::new("Test workflow 1", "echo hello world");
add_workflow(workflow_id1, workflow1, &mut app);
let workflow_id2 = SyncId::ServerId(WorkflowId::from(456).into());
let workflow2 = Workflow::new("Test workflow 2", "echo goodbye world");
add_workflow(workflow_id2, workflow2, &mut app);
let notebook_id = SyncId::ServerId(NotebookId::from(789).into());
add_notebook(
notebook_id,
"Test notebook",
"This is a test notebook",
&mut app,
);
let exporter = ExportTest::new(&mut app);
// Prepare export IDs for all three objects
let export_ids = vec![
CloudObjectTypeAndId::from_id_and_type(workflow_id1, ObjectType::Workflow),
CloudObjectTypeAndId::from_id_and_type(workflow_id2, ObjectType::Workflow),
CloudObjectTypeAndId::from_id_and_type(notebook_id, ObjectType::Notebook),
];
// Create channels for all exports
let mut receivers = Vec::new();
{
let mut pending_exports = exporter.pending_exports.lock();
for &id in &export_ids {
let (tx, rx) = oneshot::channel();
pending_exports.insert(ExportId(id, Space::Personal), tx);
receivers.push(rx);
}
}
ExportManager::handle(&app).update(&mut app, |export_manager, ctx| {
let window_id = WindowId::new();
export_manager.export(window_id, &export_ids, ctx);
let all_export_ids = export_ids
.iter()
.map(|&id| ExportId(id, Space::Personal))
.collect::<Vec<_>>();
export_manager.handle_files_picked(
all_export_ids,
Ok(vec![exporter
.target_dir
.path()
.to_str()
.expect("Path must be UTF-8")
.to_owned()]),
ShellFamily::Posix,
ctx,
);
});
// Wait for all exports to complete
for rx in receivers {
let result = rx.await;
assert!(
matches!(result, Ok(ExportEvent::Completed { .. })),
"Export failed or was canceled"
);
}
// Verify the contents of each exported file
let workflow1_path = exporter.path("Test workflow 1.yaml", Some(Space::Personal), &app);
let workflow1_contents =
fs::read_to_string(&workflow1_path).expect("Failed to read workflow 1");
assert!(workflow1_contents.contains("echo hello world"));
let workflow2_path = exporter.path("Test workflow 2.yaml", Some(Space::Personal), &app);
let workflow2_contents =
fs::read_to_string(&workflow2_path).expect("Failed to read workflow 2");
assert!(workflow2_contents.contains("echo goodbye world"));
let notebook_path = exporter.path("Test notebook.md", Some(Space::Personal), &app);
let notebook_contents =
fs::read_to_string(&notebook_path).expect("Failed to read notebook");
assert!(notebook_contents.contains("This is a test notebook"));
// Check that all files were created
assert!(workflow1_path.exists(), "Workflow 1 file does not exist");
assert!(workflow2_path.exists(), "Workflow 2 file does not exist");
assert!(notebook_path.exists(), "Notebook file does not exist");
});
}
+176
View File
@@ -0,0 +1,176 @@
use std::sync::Arc;
use super::items::folder::WarpDriveFolder;
use super::items::WarpDriveItem;
use super::CloudObjectTypeAndId;
use crate::server::cloud_objects::update_manager::InitiatedBy;
use crate::{
appearance::Appearance,
cloud_object::{
CloudModelType, CloudObjectEventEntrypoint, CreateCloudObjectResult, CreateObjectRequest,
GenericCloudObject, GenericServerObject, ObjectType, Revision, ServerCloudObject, Space,
UpdateCloudObjectResult,
},
persistence::ModelEvent,
server::{
ids::{ServerId, SyncId},
server_api::object::ObjectClient,
sync_queue::{QueueItem, SerializedModel},
},
};
use anyhow::Result;
use async_trait::async_trait;
// Re-exported from warp_server_client.
pub use warp_server_client::ids::FolderId;
/// The model for a `CloudFolder`.
#[derive(Clone, Debug, PartialEq)]
pub struct CloudFolderModel {
pub name: String,
// TODO: since this is local only state, we should consider only surfacing it as part of the
// CloudViewModel. Right now, every server folder uses CloudFolderModel, which means it
// hardcodes a value of `false` for this property since it can't know what the local state is.
pub is_open: bool,
pub is_warp_pack: bool,
}
impl CloudFolderModel {
pub fn new(name: &str, is_warp_pack: bool) -> Self {
Self {
name: name.to_owned(),
is_open: false,
is_warp_pack,
}
}
}
/// `CloudFolder` is a folder retrieved from the server.
pub type CloudFolder = GenericCloudObject<FolderId, CloudFolderModel>;
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl CloudModelType for CloudFolderModel {
type CloudObjectType = CloudFolder;
type IdType = FolderId;
fn model_type_name(&self) -> &'static str {
"Folder"
}
fn object_type(&self) -> ObjectType {
ObjectType::Folder
}
fn cloud_object_type_and_id(&self, id: SyncId) -> CloudObjectTypeAndId {
CloudObjectTypeAndId::Folder(id)
}
fn display_name(&self) -> String {
self.name.clone()
}
fn upsert_event(&self, folder: &CloudFolder) -> ModelEvent {
ModelEvent::UpsertFolder {
folder: folder.clone(),
}
}
fn bulk_upsert_event(objects: &[CloudFolder]) -> ModelEvent {
ModelEvent::UpsertFolders(objects.to_vec())
}
fn create_object_queue_item(
&self,
folder: &CloudFolder,
entrypoint: CloudObjectEventEntrypoint,
initiated_by: InitiatedBy,
) -> Option<QueueItem> {
if let SyncId::ClientId(client_id) = folder.id {
return Some(QueueItem::CreateObject {
object_type: self.object_type(),
serialized_model: Some(Arc::new(folder.model().name.clone().into())),
title: None,
owner: folder.permissions.owner,
id: client_id,
initial_folder_id: folder.metadata.folder_id,
entrypoint,
initiated_by,
});
}
None
}
fn update_object_queue_item(
&self,
_revision_ts: Option<Revision>,
folder: &CloudFolder,
) -> QueueItem {
QueueItem::UpdateFolder {
id: folder.id,
model: folder.model().clone().into(),
}
}
fn should_update_after_server_conflict(&self) -> bool {
false
}
fn serialized(&self) -> SerializedModel {
SerializedModel::new(self.name.to_owned())
}
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
if let ServerCloudObject::Folder(server_folder) = server_cloud_object {
return Some(CloudFolderModel {
name: server_folder.model.name.clone(),
is_open: self.is_open,
is_warp_pack: server_folder.model.is_warp_pack,
});
}
None
}
fn can_move_to_space(&self, current_space: Space, new_space: Space) -> bool {
// We don't currently support moving folders across spaces.
current_space == new_space
}
fn supports_linking(&self) -> bool {
true
}
async fn send_create_request(
object_client: Arc<dyn ObjectClient>,
request: CreateObjectRequest,
) -> Result<CreateCloudObjectResult> {
object_client.create_folder(request).await
}
async fn send_update_request(
&self,
object_client: Arc<dyn ObjectClient>,
server_id: ServerId,
_revision: Option<Revision>,
) -> Result<UpdateCloudObjectResult<GenericServerObject<FolderId, Self>>> {
object_client
.update_folder(server_id.into(), self.name.clone().into())
.await
}
fn renders_in_warp_drive(&self) -> bool {
true
}
fn to_warp_drive_item(
&self,
id: SyncId,
_appearance: &Appearance,
folder: &CloudFolder,
) -> Option<Box<dyn WarpDriveItem>> {
Some(Box::new(WarpDriveFolder::new(
self.cloud_object_type_and_id(id),
folder.clone(),
)))
}
}
+42
View File
@@ -0,0 +1,42 @@
use std::env::current_dir;
use warp_core::ui::appearance::Appearance;
use warpui::App;
use crate::{
cloud_object::model::persistence::CloudModel,
network::NetworkStatus,
server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue},
workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces},
GlobalResourceHandles, GlobalResourceHandlesProvider,
};
use super::expand_dirs;
#[test]
fn test_expand_directories() {
App::test((), |mut app| async move {
app.update(crate::settings::init_and_register_user_preferences);
let global_resource_handles = GlobalResourceHandles::mock(&mut app);
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
let directory = current_dir()
.expect("current directory should exist")
.parent()
.expect("parent directory should exist")
.to_path_buf()
.join("crates")
.join("integration");
// Open a folder and verify we could expand it into the correct folder tree structure.
assert_eq!(warpui::r#async::block_on(expand_dirs([directory].into_iter().collect())).debug_print(), "(integration(tests(INTEGRATION_TESTING, data(test, test_launch_config, test_theme, test_theme_with_name, test_workflow))))");
});
}
+4
View File
@@ -0,0 +1,4 @@
pub mod modal;
mod modal_body;
mod nodes;
mod queue;
+385
View File
@@ -0,0 +1,385 @@
use crate::{
appearance::Appearance,
cloud_object::{model::persistence::CloudModel, CloudObject, Owner},
server::{ids::SyncId, sync_queue::SyncQueue},
themes::theme::WarpTheme,
workspaces::user_workspaces::UserWorkspaces,
};
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::theme::Fill;
use warpui::{
elements::{
Align, Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, Flex, Highlight, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, ScrollbarWidth,
Shrinkable, Stack, Text,
},
platform::{FilePickerConfiguration, FileType},
presenter::ChildView,
ui_components::{
button::ButtonVariant,
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use super::modal_body::{ImportModalBody, ImportModalBodyAction, ImportModalBodyEvent};
const CLOSE_BUTTON_SIZE: f32 = 24.;
const HEADER_FONT_SIZE: f32 = 16.;
const MODAL_CORNER_RADIUS: f32 = 8.;
pub const BODY_HEIGHT: f32 = 244.;
#[derive(Debug)]
pub enum ImportModalAction {
Close,
}
pub enum ImportModalEvent {
OpenTargetWithHashedId(String),
Close,
}
pub struct ImportModal {
import_modal: ViewHandle<ImportModalBody>,
owner: Option<Owner>,
folder_id: Option<SyncId>,
clipped_scroll_state: ClippedScrollStateHandle,
close_button_mouse_state: MouseStateHandle,
footer_button_mouse_state: MouseStateHandle,
}
impl ImportModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let import_modal_body = ctx.add_typed_action_view(ImportModalBody::new);
ctx.subscribe_to_view(&import_modal_body, move |me, _, event, ctx| {
me.handle_import_body_event(event, ctx);
});
Self {
import_modal: import_modal_body,
owner: None,
folder_id: None,
clipped_scroll_state: Default::default(),
close_button_mouse_state: Default::default(),
footer_button_mouse_state: Default::default(),
}
}
pub fn open_with_target(
&mut self,
owner: Owner,
initial_folder_id: Option<SyncId>,
ctx: &mut ViewContext<Self>,
) {
// TODO: This should take an owner OR folder.
self.owner = Some(owner);
self.folder_id = initial_folder_id;
self.import_modal.update(ctx, |import_modal, _ctx| {
import_modal.set_new_target(owner, initial_folder_id);
});
ctx.notify();
}
pub fn open_file_picker(&mut self, ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
let import_body_id = self.import_modal.id();
let sync_queue_is_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
let allowed_file_types = vec![FileType::Yaml, FileType::Markdown];
let mut file_picker_config = FilePickerConfiguration::new()
.allow_multi_select()
.set_allowed_file_types(allowed_file_types);
// Files under a folder could only be uploaded when the folder is created on the server.
// When sync queue is not dequeueing, disable folder upload in the import modal.
if sync_queue_is_dequeueing {
file_picker_config = file_picker_config.allow_folder();
}
ctx.open_file_picker(
move |result, ctx| match result {
Ok(paths) if !paths.is_empty() => {
ctx.dispatch_typed_action_for_view(
window_id,
import_body_id,
&ImportModalBodyAction::PathsSelected(paths),
);
}
Ok(_) => {
ctx.dispatch_typed_action_for_view(
window_id,
import_body_id,
&ImportModalBodyAction::FilePickerCancelled,
);
}
Err(err) => {
ctx.dispatch_typed_action_for_view(
window_id,
import_body_id,
&ImportModalBodyAction::FilePickerError(err),
);
}
},
file_picker_config,
);
ctx.notify();
}
fn handle_import_body_event(
&mut self,
event: &ImportModalBodyEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ImportModalBodyEvent::OpenFilePicker => self.open_file_picker(ctx),
ImportModalBodyEvent::OpenTargetWithHashedId(hashed_id) => {
ctx.emit(ImportModalEvent::OpenTargetWithHashedId(hashed_id.clone()))
}
ImportModalBodyEvent::UploadCompleted
| ImportModalBodyEvent::AllFileSavedLocally
| ImportModalBodyEvent::UploadSelected => ctx.notify(),
}
}
fn breadcrumb(&self, app: &AppContext) -> (String, Vec<usize>) {
let (text, highlight_start) = match self
.folder_id
.as_ref()
.and_then(|folder_id| CloudModel::as_ref(app).get_folder(folder_id))
{
Some(folder) => {
let breadcrumbs = folder.breadcrumbs(app);
(
format!("{} / {}", breadcrumbs, folder.display_name()),
breadcrumbs.chars().count() + 3,
)
}
None => (
// Convert to a Space for display, in case we're importing into a shared folder.
self.owner
.map(|owner| {
UserWorkspaces::as_ref(app)
.owner_to_space(owner, app)
.name(app)
})
.unwrap_or_default(),
0,
),
};
// The unit of highlight index is character index not byte index.
let highlight_range = (highlight_start..text.chars().count()).collect();
(text, highlight_range)
}
fn render_breadcrumbs(
&self,
appearance: &Appearance,
theme: &WarpTheme,
app: &AppContext,
) -> Box<dyn Element> {
let (breadcrumb_text, highlight_indices) = self.breadcrumb(app);
Container::new(
appearance
.ui_builder()
.span(breadcrumb_text)
.with_highlights(
highlight_indices,
Highlight::new().with_foreground_color(
theme.main_text_color(theme.surface_2()).into_solid(),
),
)
.with_style(UiComponentStyles {
font_color: Some(theme.sub_text_color(theme.surface_2()).into_solid()),
..Default::default()
})
.build()
.finish(),
)
.with_margin_top(6.)
.finish()
}
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
appearance
.ui_builder()
.close_button(CLOSE_BUTTON_SIZE, self.close_button_mouse_state.clone())
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(ImportModalAction::Close))
.finish()
}
fn render_header(
&self,
appearance: &Appearance,
theme: &WarpTheme,
app: &AppContext,
) -> Box<dyn Element> {
let top_row = Flex::row()
.with_child(
Shrinkable::new(
1.0,
Align::new(
Text::new_inline("Import", appearance.ui_font_family(), HEADER_FONT_SIZE)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.left()
.finish(),
)
.finish(),
)
.with_child(self.render_close_button(appearance))
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish();
let header = Flex::column()
.with_child(top_row)
.with_child(self.render_breadcrumbs(appearance, theme, app))
.finish();
Container::new(header)
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(MODAL_CORNER_RADIUS)))
.with_padding_left(24.)
.with_padding_top(16.)
.with_padding_right(16.)
.with_padding_bottom(16.)
.with_border(Border::bottom(1.).with_border_fill(theme.outline()))
.finish()
}
fn render_body(&self, theme: &WarpTheme) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(
ClippedScrollable::vertical(
self.clipped_scroll_state.clone(),
ChildView::new(&self.import_modal).finish(),
ScrollbarWidth::Auto,
theme.disabled_text_color(theme.surface_2()).into(),
theme.main_text_color(theme.surface_2()).into(),
theme.surface_2().into(),
)
.finish(),
)
.with_height(BODY_HEIGHT)
.finish(),
)
.with_uniform_padding(5.)
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(
MODAL_CORNER_RADIUS,
)))
.with_background(theme.surface_2())
.finish()
}
fn render_footer(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let button_text = if !self.import_modal.as_ref(app).upload_in_progress(app) {
"Close".to_string()
} else {
"Cancel".to_string()
};
Container::new(
Align::new(
appearance
.ui_builder()
.button(
ButtonVariant::Outlined,
self.footer_button_mouse_state.clone(),
)
.with_centered_text_label(button_text.to_string())
.with_style(UiComponentStyles {
width: Some(150.),
height: Some(40.),
font_size: Some(14.),
..Default::default()
})
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(ImportModalAction::Close))
.finish(),
)
.right()
.finish(),
)
.with_uniform_padding(16.)
.finish()
}
}
impl Entity for ImportModal {
type Event = ImportModalEvent;
}
impl View for ImportModal {
fn ui_name() -> &'static str {
"ImportModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut contents = Flex::column()
.with_child(self.render_header(appearance, theme, app))
.with_child(self.render_body(theme));
if !self.import_modal.as_ref(app).before_upload() {
contents.add_child(self.render_footer(appearance, app));
}
let modal = ConstrainedBox::new(
Container::new(contents.finish())
.with_background(theme.surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(MODAL_CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_margin_top(35.)
.finish(),
)
.with_width(500.)
.with_height(300.)
.finish();
// Stack needed so that modal can get bounds information,
// specifically to ensure no overlap with the window's traffic lights
let mut stack = Stack::new();
stack.add_positioned_child(
modal,
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
Container::new(Align::new(stack.finish()).finish())
.with_background_color(Fill::blur().into())
.finish()
}
}
impl TypedActionView for ImportModal {
type Action = ImportModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ImportModalAction::Close => {
self.import_modal.update(ctx, |import_modal_body, ctx| {
import_modal_body.reset(ctx);
});
ctx.emit(ImportModalEvent::Close);
}
}
}
}
+583
View File
@@ -0,0 +1,583 @@
use futures_util::stream::AbortHandle;
use pathfinder_geometry::vector::vec2f;
use std::path::PathBuf;
use warpui::{
elements::{
Align, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
},
platform::{file_picker::FilePickerError, Cursor},
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::{
appearance::Appearance,
cloud_object::Owner,
server::{
ids::{ClientId, SyncId},
sync_queue::SyncQueue,
},
ui_components::icons::Icon,
view_components::DismissibleToast,
workspace::ToastStack,
};
use super::{
modal::BODY_HEIGHT,
nodes::{
expand_dirs, parse_file, FileContent, FileId, FileUploadState, FolderId, UploadResult,
},
queue::{ImportQueue, ImportQueueArgs, ImportQueueEvent, ParentId, RequestContent},
};
const FILE_PICKER_BUTTON_WIDTH: f32 = 250.;
const BUTTON_FONT_SIZE: f32 = 14.;
const BUTTON_BORDER_RADIUS: f32 = 4.;
pub(super) const IMPORT_FONT_SIZE: f32 = 14.;
pub(super) const INDENT_MARGIN: f32 = 22.;
pub(super) const BASE_INDENT: f32 = 30.;
const FILE_TYPE_DOCS_URL: &str =
"https://docs.warp.dev/knowledge-and-collaboration/warp-drive#import-and-export";
const SUPPORTED_FILE_TYPE_TEXT: &str = "md, yaml, yml";
#[cfg(test)]
#[path = "import_tests.rs"]
mod import_tests;
/// Current state of the import modal.
///
/// The entire import flow goes as follows:
/// 1. Modal prompts user with native file picker
/// 2. User selects paths from the file picker
/// 3. The modal expands paths into a tree of folders and matching files
/// 4. Insert all folders we need to upload into the import queue
/// 5. Iteratively parse out all file contents from the file paths
/// - If we fail to parse the file, mark the file node as errored
/// - If we successfully parsed the file, push the file content to the import queue
enum ImportState {
// Before the user opens the file picker.
Upload,
// We are waiting for users to select paths from the file picker.
Loading,
// Users have selected paths from the file picker.
PathLoaded,
PathExpanded(FileUploadState),
}
#[derive(Debug)]
pub enum ImportModalBodyAction {
RetryFile(FileId),
OpenFilePicker,
FilePickerCancelled,
PathsSelected(Vec<String>),
FilePickerError(FilePickerError),
ClickedToOpenTarget(String),
}
pub enum ImportModalBodyEvent {
OpenFilePicker,
UploadCompleted,
AllFileSavedLocally,
UploadSelected,
OpenTargetWithHashedId(String),
}
pub struct ImportModalBody {
state: ImportState,
in_progress_handle: Option<AbortHandle>,
// Queue to handle requests to upload objects to warp drive.
// All updates should go through the queue rather than calling
// UpdateManager directly.
import_queue: ModelHandle<ImportQueue>,
owner: Option<Owner>,
initial_folder_id: Option<SyncId>,
file_picker_mouse_state: MouseStateHandle,
link_mouse_state: MouseStateHandle,
}
impl ImportModalBody {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let import_queue = ctx.add_model(ImportQueue::new);
ctx.subscribe_to_model(&import_queue, |me, _, event, ctx| {
me.handle_import_queue_event(event, ctx)
});
Self {
state: ImportState::Upload,
owner: None,
initial_folder_id: None,
import_queue,
file_picker_mouse_state: Default::default(),
link_mouse_state: Default::default(),
in_progress_handle: None,
}
}
fn handle_import_queue_event(&mut self, event: &ImportQueueEvent, ctx: &mut ViewContext<Self>) {
// Only handle event when path is expanded.
if let ImportState::PathExpanded(state) = &mut self.state {
match event {
ImportQueueEvent::FileCompleted { file_id, server_id } => {
let result = match server_id {
Some(id) => UploadResult::Success(id.clone()),
None => UploadResult::Error("Failed to upload file to server".to_string()),
};
// Update the upstream folder status with the upload success state.
if state.update_tree_with_file_upload_result(result, *file_id) {
ctx.notify();
}
}
ImportQueueEvent::FolderCompleted {
folder_id,
server_id,
} => {
let result = match server_id {
Some(id) => UploadResult::Success(id.clone()),
None => {
UploadResult::Error("Failed to upload folder to server".to_string())
}
};
state.mark_folder_synced(result, *folder_id);
ctx.notify();
}
ImportQueueEvent::FileSavedLocally(file_id) => {
let file_node = state
.file_id_to_node
.get_mut(file_id)
.expect("File node should exist");
file_node.saved_locally();
ctx.notify();
}
}
let sync_queue_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
if !sync_queue_dequeueing && state.all_files_saved_locally() {
ctx.emit(ImportModalBodyEvent::AllFileSavedLocally);
} else if state.is_complete() {
ctx.emit(ImportModalBodyEvent::UploadCompleted);
}
}
}
pub fn set_new_target(&mut self, owner: Owner, initial_folder_id: Option<SyncId>) {
// TODO: this should take an owner OR folder.
self.owner = Some(owner);
self.initial_folder_id = initial_folder_id;
}
// Push a new update to the import queue to sync with the server.
fn push_new_update(&mut self, arg: ImportQueueArgs, ctx: &mut ViewContext<Self>) {
self.import_queue
.update(ctx, |queue, ctx| queue.enqueue(arg, ctx));
}
// Whether there is an active upload in progress (If all uploads are completed,
// we don't consider the import modal upload to be in progress).
pub fn upload_in_progress(&self, app: &AppContext) -> bool {
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
match &self.state {
ImportState::Upload => false,
ImportState::PathExpanded(state)
if !sync_queue_dequeueing && state.all_files_saved_locally() =>
{
false
}
ImportState::PathExpanded(state) if state.is_complete() => false,
_ => true,
}
}
pub fn before_upload(&self) -> bool {
matches!(&self.state, ImportState::Upload | ImportState::Loading)
}
fn parent_id_for_upload(&self, parent_folder_cloud_id: Option<ClientId>) -> ParentId {
match parent_folder_cloud_id {
Some(id) => ParentId::FolderToUpload(id),
None => ParentId::InitialFolder(self.initial_folder_id),
}
}
/// Populate folder nodes with actual cloud objects.
pub(super) fn populate_folder_cloud_object(
&mut self,
state: &mut FileUploadState,
ctx: &mut ViewContext<Self>,
) {
// Start with the first non-root node folder.
let mut id = FolderId::root_id();
id += 1;
let Some(owner) = self.owner else {
log::warn!("Import modal opened without owner");
return;
};
// Push all folders to the queue in order. This is more time efficient when dequeueing
// from the queue.
while let Some(node) = state.folder_id_to_node.get(&id) {
let parent_id = node.parent_id();
// If a node's parent is the root / dummy node, consider it to have no initial folder.
let parent_folder_cloud_id = state.folder_cloud_id(parent_id);
self.push_new_update(
ImportQueueArgs {
owner,
parent_id: self.parent_id_for_upload(parent_folder_cloud_id),
content: RequestContent::Folder {
name: node.name(),
client_id: node.cloud_id(),
folder_id: id,
},
},
ctx,
);
id += 1;
}
}
/// Parse the next file that has not been uploaded. We determine the next file by iteratively
/// by adding 1 to the previous uploaded file id.
fn parse_next_file(
&mut self,
file_id: FileId,
continue_parsing_after_completion: bool,
ctx: &mut ViewContext<Self>,
) {
if let ImportState::PathExpanded(state) = &self.state {
let Some(node) = state.file_id_to_node.get(&file_id) else {
return;
};
let path = node.full_path();
let file_type = node.file_type();
let handle = ctx.spawn(parse_file(path, file_type), move |view, response, ctx| {
let metadata = match &mut view.state {
ImportState::PathExpanded(state) => {
// If there is an error with the file, update the file state with the error and notify
// upstream folders.
if let Err(e) = &response {
state.update_tree_with_file_upload_result(
UploadResult::Error(e.to_string()),
file_id,
);
}
state.file_name_and_parent_cloud_id(file_id)
}
_ => None,
};
let Some((file_name, parent_cloud_id)) = metadata else {
return;
};
let Some(owner) = view.owner else {
log::warn!("Import modal opened without owner");
return;
};
match response {
Ok(FileContent::Notebook(data)) => {
let client_id = ClientId::default();
view.push_new_update(
ImportQueueArgs {
owner,
parent_id: view.parent_id_for_upload(parent_cloud_id),
content: RequestContent::Notebook {
title: file_name,
data,
client_id,
file_id,
},
},
ctx,
)
}
Ok(FileContent::Workflow {
workflows,
workflow_enums,
}) => view.push_new_update(
ImportQueueArgs {
owner,
parent_id: view.parent_id_for_upload(parent_cloud_id),
content: RequestContent::Workflow {
workflows: workflows
.into_iter()
.map(|workflow| (workflow, ClientId::new()))
.collect(),
workflow_enums,
file_id,
},
},
ctx,
),
_ => (),
}
let next_file_id = file_id + 1;
match &mut view.state {
ImportState::PathExpanded(state) => {
if continue_parsing_after_completion
&& state.file_id_to_node.contains_key(&next_file_id)
{
view.parse_next_file(
next_file_id,
continue_parsing_after_completion,
ctx,
);
} else {
// If we reach the end of the parsable files or should not continue parsing, reset the abort handle.
view.in_progress_handle = None;
}
}
_ => panic!("Validated state is path expanded already"),
};
ctx.notify();
});
self.in_progress_handle = Some(handle.abort_handle());
} else {
log::error!("State should be path expanded when parsing files");
};
}
pub fn reset(&mut self, ctx: &mut ViewContext<Self>) {
self.state = ImportState::Upload;
if let Some(handle) = self.in_progress_handle.take() {
handle.abort();
}
ctx.notify();
}
fn render_upload_state(&self, appearance: &Appearance) -> Box<dyn Element> {
let is_loading = matches!(self.state, ImportState::PathLoaded | ImportState::Loading);
let base_button = appearance
.ui_builder()
.button(ButtonVariant::Accent, self.file_picker_mouse_state.clone())
.with_style(UiComponentStyles {
font_size: Some(BUTTON_FONT_SIZE),
font_family_id: Some(appearance.ui_font_family()),
padding: Some(Coords {
top: 10.,
bottom: 10.,
left: 70.,
right: 70.,
}),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(BUTTON_BORDER_RADIUS))),
border_color: Some(appearance.theme().outline().into()),
width: Some(FILE_PICKER_BUTTON_WIDTH),
..Default::default()
});
let file_picker_button = if is_loading {
base_button
.with_centered_text_label("Preparing...".to_string())
.disabled()
} else {
base_button.with_text_and_icon_label(
TextAndIcon::new(
TextAndIconAlignment::TextFirst,
"Choose files...".to_string(),
Icon::Import.to_warpui_icon(
appearance
.theme()
.main_text_color(appearance.theme().accent_button_color()),
),
MainAxisSize::Max,
MainAxisAlignment::Center,
vec2f(16., 16.),
)
.with_inner_padding(4.),
)
};
let file_picker_element = file_picker_button
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ImportModalBodyAction::OpenFilePicker)
})
.with_cursor(Cursor::PointingHand)
.finish();
let supported_file_type = appearance
.ui_builder()
.span(SUPPORTED_FILE_TYPE_TEXT)
.with_style(UiComponentStyles {
font_color: Some(
appearance
.theme()
.hint_text_color(appearance.theme().surface_2())
.into_solid(),
),
..Default::default()
})
.build()
.finish();
let link_to_document = appearance
.ui_builder()
.link(
"Learn about file support and formatting".to_string(),
Some(FILE_TYPE_DOCS_URL.to_string()),
None,
self.link_mouse_state.clone(),
)
.soft_wrap(false)
.build()
.finish();
ConstrainedBox::new(
Align::new(
Flex::column()
.with_child(file_picker_element)
.with_child(
Container::new(supported_file_type)
.with_margin_top(16.)
.with_margin_bottom(16.)
.finish(),
)
.with_child(link_to_document)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
)
.finish(),
)
.with_height(BODY_HEIGHT)
.finish()
}
fn render_loaded_state(
&self,
file_upload_state: &FileUploadState,
sync_queue_dequeueing: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut column = Flex::column();
let folder_id_to_node = &file_upload_state.folder_id_to_node;
let file_id_to_node = &file_upload_state.file_id_to_node;
let folder_node = folder_id_to_node
.get(&FolderId::root_id())
.expect("Root node should exist");
for item in folder_node.children() {
column.add_child(item.render(
appearance,
0,
file_upload_state.is_complete(),
sync_queue_dequeueing,
folder_id_to_node,
file_id_to_node,
));
}
Container::new(column.finish())
.with_margin_left(10.)
.with_margin_top(20.)
.with_margin_right(10.)
.finish()
}
}
impl Entity for ImportModalBody {
type Event = ImportModalBodyEvent;
}
impl View for ImportModalBody {
fn ui_name() -> &'static str {
"ImportModalBody"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
let appearance = Appearance::as_ref(app);
match &self.state {
ImportState::Upload | ImportState::Loading | ImportState::PathLoaded => {
self.render_upload_state(appearance)
}
ImportState::PathExpanded(paths) => {
self.render_loaded_state(paths, sync_queue_dequeueing, appearance)
}
}
}
}
impl TypedActionView for ImportModalBody {
type Action = ImportModalBodyAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ImportModalBodyAction::OpenFilePicker => {
self.state = ImportState::Loading;
ctx.emit(ImportModalBodyEvent::OpenFilePicker);
ctx.notify();
}
ImportModalBodyAction::FilePickerCancelled => {
self.state = ImportState::Upload;
ctx.emit(ImportModalBodyEvent::UploadSelected);
ctx.notify();
}
ImportModalBodyAction::FilePickerError(err) => {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(format!("{err}")),
window_id,
ctx,
);
});
self.state = ImportState::Upload;
ctx.emit(ImportModalBodyEvent::UploadSelected);
ctx.notify();
}
ImportModalBodyAction::RetryFile(file_id) => {
if let ImportState::PathExpanded(state) = &mut self.state {
state.set_file_and_parent_to_loading(*file_id);
}
self.parse_next_file(*file_id, false, ctx);
ctx.notify();
}
ImportModalBodyAction::ClickedToOpenTarget(hashed_id) => {
self.reset(ctx);
ctx.emit(ImportModalBodyEvent::OpenTargetWithHashedId(
hashed_id.clone(),
));
}
ImportModalBodyAction::PathsSelected(paths) => {
self.state = ImportState::PathLoaded;
let paths_cloned = paths.clone();
let handle = ctx.spawn(
expand_dirs(paths_cloned.into_iter().map(PathBuf::from).collect()),
move |view, mut upload_state, ctx| {
view.populate_folder_cloud_object(&mut upload_state, ctx);
view.state = ImportState::PathExpanded(upload_state);
view.parse_next_file(FileId::first_id(), true, ctx);
ctx.notify();
},
);
self.in_progress_handle = Some(handle.abort_handle());
ctx.notify();
ctx.emit(ImportModalBodyEvent::UploadSelected);
}
}
}
}
+194
View File
@@ -0,0 +1,194 @@
use std::{collections::HashMap, path::PathBuf};
use crate::drive::import::nodes::{UploadResult, UploadStatus};
use super::{FileId, FileNode, FileType, FileUploadState, FolderId, FolderNode, ImportedNode};
fn mock_tree() -> FileUploadState {
let mut folder_id_to_node = HashMap::new();
let mut file_id_to_node = HashMap::new();
let mut root_folder = FolderNode::new(String::new(), FolderId(0));
let top_level_file = FileNode::new(
"top_level".to_string(),
FileType::Notebook,
PathBuf::new(),
FolderId(0),
);
let mut top_level_folder = FolderNode::new("top_folder".to_string(), FolderId::root_id());
let second_level_file = FileNode::new(
"second_level".to_string(),
FileType::Workflow,
PathBuf::new(),
FolderId(1),
);
top_level_folder
.children
.push(ImportedNode::File(FileId(1)));
root_folder.children.push(ImportedNode::File(FileId(0)));
root_folder.children.push(ImportedNode::Folder(FolderId(1)));
file_id_to_node.insert(FileId(1), second_level_file);
file_id_to_node.insert(FileId(0), top_level_file);
folder_id_to_node.insert(FolderId(1), top_level_folder);
folder_id_to_node.insert(FolderId(0), root_folder);
let state = FileUploadState {
folder_id_to_node,
file_id_to_node,
};
assert_eq!(state.debug_print(), "(top_folder(second_level), top_level)");
state
}
#[test]
fn test_state_update_in_tree() {
let mut state = mock_tree();
state.mark_folder_synced(
UploadResult::Success("mock-folder".to_string()),
FolderId(1),
);
// Only the second level file is loaded. Top-level folder should be loaded but
// root folder should still be loading.
state.update_tree_with_file_upload_result(
UploadResult::Success("mock-markdown".to_string()),
FileId(1),
);
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(1))
.expect("Should exist")
.status,
UploadStatus::Loaded("mock-folder".to_string())
);
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(0))
.expect("Should exist")
.status,
UploadStatus::Loading
);
// Top level file is also loaded. Root level folder should be marked as loaded.
state.update_tree_with_file_upload_result(
UploadResult::Success("mock-root".to_string()),
FileId(0),
);
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(0))
.expect("Should exist")
.status,
UploadStatus::Loaded(String::new())
);
// Set second level file to be loading. All of the folders should be loading.
state.set_file_and_parent_to_loading(FileId(1));
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(1))
.expect("Should exist")
.status,
UploadStatus::Loading
);
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(0))
.expect("Should exist")
.status,
UploadStatus::Loading
);
// Second level file finished loading. All of the folders should be loaded.
state.update_tree_with_file_upload_result(
UploadResult::Success("mock-folder".to_string()),
FileId(1),
);
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(1))
.expect("Should exist")
.status,
UploadStatus::Loaded("mock-folder".to_string())
);
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(0))
.expect("Should exist")
.status,
UploadStatus::Loaded(String::new())
);
}
#[test]
fn test_empty_folders_update() {
let mut folder_id_to_node = HashMap::new();
let file_id_to_node = HashMap::new();
let mut root_folder = FolderNode::new(String::new(), FolderId(0));
let empty_folder_1 = FolderNode::new("empty".to_string(), FolderId(0));
let empty_folder_2 = FolderNode::new("empty1".to_string(), FolderId(0));
root_folder.children.push(ImportedNode::Folder(FolderId(1)));
root_folder.children.push(ImportedNode::Folder(FolderId(2)));
folder_id_to_node.insert(FolderId(0), root_folder);
folder_id_to_node.insert(FolderId(1), empty_folder_1);
folder_id_to_node.insert(FolderId(2), empty_folder_2);
let mut state = FileUploadState {
folder_id_to_node,
file_id_to_node,
};
assert_eq!(state.debug_print(), "(empty, empty1)");
state.mark_folder_synced(
UploadResult::Success("mock-folder".to_string()),
FolderId(1),
);
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(1))
.expect("Should exist")
.status,
UploadStatus::Loaded("mock-folder".to_string())
);
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(0))
.expect("Should exist")
.status,
UploadStatus::Loading
);
// Errored uploads should also be considered as completed uploads.
state.mark_folder_synced(UploadResult::Error("Failure".to_string()), FolderId(2));
assert_eq!(
state
.folder_id_to_node
.get(&FolderId(0))
.expect("Should exist")
.status,
UploadStatus::Loaded(String::new())
);
}
+963
View File
@@ -0,0 +1,963 @@
use crate::{
drive::{cloud_object_styling::warp_drive_icon_color, DriveObjectType},
notebooks::post_process_notebook,
workflows::{
export_workflow::export_deserialize, workflow::Workflow, workflow_enum::WorkflowEnum,
},
};
use anyhow::Result;
use async_recursion::async_recursion;
use futures_lite::StreamExt;
use pathfinder_color::ColorU;
use std::{
collections::HashMap,
ffi::OsStr,
ops::{Add, AddAssign, SubAssign},
path::{Path, PathBuf},
};
use warpui::{
elements::{
Align, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
MouseStateHandle, ParentElement, Radius, Shrinkable,
},
platform::Cursor,
ui_components::components::{UiComponent, UiComponentStyles},
Element,
};
use crate::{
appearance::Appearance, notebooks::file::is_markdown_file, server::ids::ClientId,
themes::theme::Fill, ui_components::icons::Icon,
};
use super::modal_body::{ImportModalBodyAction, BASE_INDENT, IMPORT_FONT_SIZE, INDENT_MARGIN};
#[cfg(test)]
#[path = "node_tests.rs"]
mod node_tests;
/// Unique ID for a file node.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct FileId(pub usize);
impl FileId {
pub(super) fn first_id() -> Self {
FileId(0)
}
}
/// Unique ID for a folder node.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub(super) struct FolderId(pub usize);
impl FolderId {
pub(super) fn root_id() -> Self {
FolderId(0)
}
}
impl From<usize> for FileId {
fn from(value: usize) -> Self {
Self(value)
}
}
impl From<usize> for FolderId {
fn from(value: usize) -> Self {
Self(value)
}
}
impl AddAssign<usize> for FileId {
fn add_assign(&mut self, rhs: usize) {
self.0 += rhs
}
}
impl Add<usize> for FileId {
type Output = Self;
fn add(self, rhs: usize) -> Self::Output {
Self(self.0 + rhs)
}
}
impl AddAssign<usize> for FolderId {
fn add_assign(&mut self, rhs: usize) {
self.0 += rhs
}
}
impl SubAssign<usize> for FolderId {
fn sub_assign(&mut self, rhs: usize) {
self.0 -= rhs
}
}
/// A node representing either a imported folder or a file.
pub(super) enum ImportedNode {
File(FileId),
Folder(FolderId),
}
impl ImportedNode {
/// Initiate the import file tree from a path.
#[async_recursion]
pub(super) async fn initiate_from_path(
full_path: PathBuf,
parent_folder_id: FolderId,
next_folder_id: &mut FolderId,
next_file_id: &mut FileId,
folder_id_to_node: &mut HashMap<FolderId, FolderNode>,
file_id_to_node: &mut HashMap<FileId, FileNode>,
) -> Option<Self> {
let name = full_path.file_stem()?.to_str()?.to_string();
if full_path.is_dir() {
let current_folder_id = *next_folder_id;
let mut folder_node = FolderNode::new(name, parent_folder_id);
*next_folder_id += 1;
let mut entries = async_fs::read_dir(full_path).await.ok()?;
// Recursively create children nodes from the files and folders under the current directory.
while let Some(entry) = entries.try_next().await.ok()? {
let child_path = entry.path();
if let Some(child_node) = ImportedNode::initiate_from_path(
child_path,
current_folder_id,
next_folder_id,
next_file_id,
folder_id_to_node,
file_id_to_node,
)
.await
{
folder_node.children.push(child_node);
}
}
// If the folder's parent is the root node, we should keep the folder node even if
// it has no children.
if !folder_node.children.is_empty() || parent_folder_id == FolderId::root_id() {
folder_id_to_node.insert(current_folder_id, folder_node);
return Some(ImportedNode::Folder(current_folder_id));
}
// If the folder children is empty, we don't consider the folder as an import node.
*next_folder_id -= 1;
} else if full_path.is_file() {
let file_type = full_path.as_path().try_into();
if let Ok(file_type) = file_type {
let file_node = FileNode::new(name, file_type, full_path, parent_folder_id);
let current_file_id = *next_file_id;
file_id_to_node.insert(current_file_id, file_node);
*next_file_id += 1;
return Some(ImportedNode::File(current_file_id));
}
}
None
}
pub(super) fn render(
&self,
appearance: &Appearance,
indent_level: usize,
allow_click_to_open_target: bool,
sync_queue_dequeueing: bool,
folder_id_to_node: &HashMap<FolderId, FolderNode>,
file_id_to_node: &HashMap<FileId, FileNode>,
) -> Box<dyn Element> {
match &self {
ImportedNode::File(file_id) => {
let file_node = file_id_to_node.get(file_id).expect("Should exist");
file_node.render(
indent_level,
sync_queue_dequeueing,
allow_click_to_open_target,
*file_id,
appearance,
)
}
ImportedNode::Folder(folder_id) => {
let folder_node = folder_id_to_node.get(folder_id).expect("Should exist");
folder_node.render(
sync_queue_dequeueing,
appearance,
indent_level,
allow_click_to_open_target,
folder_id_to_node,
file_id_to_node,
)
}
}
}
#[cfg(test)]
fn debug_print(
&self,
folder_id_to_node: &HashMap<FolderId, FolderNode>,
file_id_to_node: &HashMap<FileId, FileNode>,
) -> String {
match &self {
ImportedNode::File(file_id) => {
let file_node = file_id_to_node.get(file_id).expect("Should exist");
file_node.debug_print()
}
ImportedNode::Folder(folder_id) => {
let folder_node = folder_id_to_node.get(folder_id).expect("Should exist");
folder_node.debug_print(folder_id_to_node, file_id_to_node)
}
}
}
}
pub(super) struct FolderNode {
parent_id: FolderId,
cloud_object_id: ClientId,
name: String,
children: Vec<ImportedNode>,
server_id: Option<String>,
all_children_synced: bool,
status: UploadStatus,
open_button_mouse_state: MouseStateHandle,
}
impl FolderNode {
fn new(name: String, parent_id: FolderId) -> Self {
Self {
name,
parent_id,
cloud_object_id: ClientId::new(),
children: Vec::new(),
server_id: None,
all_children_synced: false,
status: UploadStatus::Loading,
open_button_mouse_state: Default::default(),
}
}
#[cfg(test)]
fn debug_print(
&self,
folder_id_to_node: &HashMap<FolderId, FolderNode>,
file_id_to_node: &HashMap<FileId, FileNode>,
) -> String {
use itertools::Itertools;
if self.children.is_empty() {
return self.name.clone();
}
let children_string = self
.children
.iter()
.map(|child_node| child_node.debug_print(folder_id_to_node, file_id_to_node))
.sorted()
.join(", ");
format!("{}({})", self.name.clone(), children_string)
}
fn are_children_saved_locally(
&self,
folder_id_to_node: &HashMap<FolderId, FolderNode>,
file_id_to_node: &HashMap<FileId, FileNode>,
) -> bool {
self.children.iter().all(|node| match node {
ImportedNode::File(file_id) => file_id_to_node
.get(file_id)
.map(|file_node| file_node.status.is_saved_locally())
.unwrap_or(true),
ImportedNode::Folder(folder_id) => folder_id_to_node
.get(folder_id)
.map(|folder_node| folder_node.status.is_loaded())
.unwrap_or(true),
})
}
// Check if the folder is loaded. This is true if all of its children are loaded.
fn are_children_loaded(
&self,
folder_id_to_node: &HashMap<FolderId, FolderNode>,
file_id_to_node: &HashMap<FileId, FileNode>,
) -> bool {
self.children.iter().all(|node| match node {
ImportedNode::File(file_id) => file_id_to_node
.get(file_id)
.map(|file_node| file_node.status.is_loaded())
.unwrap_or(true),
ImportedNode::Folder(folder_id) => folder_id_to_node
.get(folder_id)
.map(|folder_node| folder_node.status.is_loaded())
.unwrap_or(true),
})
}
pub(super) fn cloud_id(&self) -> ClientId {
self.cloud_object_id
}
pub(super) fn name(&self) -> String {
self.name.clone()
}
pub(super) fn parent_id(&self) -> FolderId {
self.parent_id
}
pub(super) fn children(&self) -> &Vec<ImportedNode> {
&self.children
}
fn render(
&self,
sync_queue_dequeueing: bool,
appearance: &Appearance,
indent_level: usize,
allow_click_to_open_target: bool,
folder_id_to_node: &HashMap<FolderId, FolderNode>,
file_id_to_node: &HashMap<FileId, FileNode>,
) -> Box<dyn Element> {
let override_color = self.status.override_text_color(appearance);
let status_icon = self
.status
.render_status_icon(sync_queue_dequeueing, appearance);
let icon_color =
override_color.unwrap_or(warp_drive_icon_color(appearance, DriveObjectType::Folder));
let icon = ConstrainedBox::new(
Icon::Folder
.to_warpui_icon(Fill::Solid(icon_color))
.finish(),
)
.with_height(IMPORT_FONT_SIZE)
.with_width(IMPORT_FONT_SIZE)
.finish();
let mut column = Flex::column();
let row = Flex::row()
.with_child(status_icon)
.with_child(Container::new(icon).with_margin_right(3.).finish())
.with_child(
appearance
.ui_builder()
.span(self.name.clone())
.with_style(UiComponentStyles {
font_size: Some(IMPORT_FONT_SIZE),
font_color: override_color,
..Default::default()
})
.build()
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish();
let total_indent = BASE_INDENT + INDENT_MARGIN * indent_level as f32;
let folder_row = match &self.status {
UploadStatus::Loaded(server_id) if allow_click_to_open_target => {
render_highlighted_pill(
row,
total_indent,
self.open_button_mouse_state.clone(),
server_id.to_owned(),
appearance,
)
}
_ => Container::new(row)
.with_margin_left(total_indent)
.with_padding_top(10.)
.with_padding_bottom(10.)
.finish(),
};
column.add_child(folder_row);
for item in &self.children {
column.add_child(item.render(
appearance,
indent_level + 1,
allow_click_to_open_target,
sync_queue_dequeueing,
folder_id_to_node,
file_id_to_node,
));
}
column.finish()
}
}
pub(super) enum FileContent {
Workflow {
workflows: Vec<Workflow>,
workflow_enums: HashMap<ClientId, WorkflowEnum>,
},
Notebook(String),
}
#[derive(Debug, Clone, Copy)]
pub(super) enum FileType {
Workflow,
Notebook,
}
impl TryFrom<&Path> for FileType {
type Error = ();
fn try_from(path: &Path) -> Result<Self, Self::Error> {
if is_markdown_file(path) {
Ok(FileType::Notebook)
} else {
let extension = path.extension();
if extension == Some(OsStr::new("yaml")) || extension == Some(OsStr::new("yml")) {
Ok(FileType::Workflow)
} else {
Err(())
}
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum UploadStatus {
Loading,
SavedLocally,
Loaded(String),
Error(String),
}
impl UploadStatus {
pub(super) fn is_loaded(&self) -> bool {
!matches!(&self, Self::Loading | Self::SavedLocally)
}
pub(super) fn is_saved_locally(&self) -> bool {
!matches!(&self, Self::Loading)
}
fn override_text_color(&self, appearance: &Appearance) -> Option<ColorU> {
match &self {
Self::Error(_) => Some(
appearance
.theme()
.sub_text_color(appearance.theme().surface_1())
.into_solid(),
),
_ => None,
}
}
fn render_status_icon(
&self,
sync_queue_dequeueing: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let status_icon_element = match &self {
UploadStatus::SavedLocally if !sync_queue_dequeueing => Icon::Laptop
.to_warpui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().surface_1()),
)
.finish(),
UploadStatus::Loading | UploadStatus::SavedLocally => Icon::Refresh
.to_warpui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().surface_1()),
)
.finish(),
UploadStatus::Loaded(_) => Icon::Check
.to_warpui_icon(Fill::Solid(ColorU::new(11, 142, 71, 255)))
.finish(),
UploadStatus::Error(_) => Icon::AlertTriangle
.to_warpui_icon(Fill::Solid(appearance.theme().ui_error_color()))
.finish(),
};
Container::new(
ConstrainedBox::new(status_icon_element)
.with_height(IMPORT_FONT_SIZE)
.with_width(IMPORT_FONT_SIZE)
.finish(),
)
.with_margin_right(8.)
.finish()
}
}
#[derive(Debug)]
pub(super) struct FileNode {
/// The display name of the file. This is not necessarily the same as its on-disk filename.
name: String,
file_type: FileType,
status: UploadStatus,
full_path: PathBuf,
parent_id: FolderId,
refresh_button_mouse_state: MouseStateHandle,
open_button_mouse_state: MouseStateHandle,
}
impl FileNode {
fn new(name: String, file_type: FileType, full_path: PathBuf, parent_id: FolderId) -> Self {
Self {
name,
file_type,
full_path,
parent_id,
status: UploadStatus::Loading,
refresh_button_mouse_state: Default::default(),
open_button_mouse_state: Default::default(),
}
}
#[cfg(test)]
fn debug_print(&self) -> String {
self.name.clone()
}
pub(super) fn full_path(&self) -> PathBuf {
self.full_path.clone()
}
pub(super) fn file_type(&self) -> FileType {
self.file_type
}
pub(super) fn saved_locally(&mut self) {
if !self.status.is_loaded() {
self.status = UploadStatus::SavedLocally;
}
}
fn render(
&self,
indent_level: usize,
sync_queue_dequeueing: bool,
allow_click_to_open_target: bool,
file_id: FileId,
appearance: &Appearance,
) -> Box<dyn Element> {
let status_icon = self
.status
.render_status_icon(sync_queue_dequeueing, appearance);
let override_color = self.status.override_text_color(appearance);
let icon_element = match &self.file_type {
FileType::Workflow => Icon::Workflow
.to_warpui_icon(Fill::Solid(override_color.unwrap_or(
warp_drive_icon_color(appearance, DriveObjectType::Workflow),
)))
.finish(),
FileType::Notebook => Icon::Notebook
.to_warpui_icon(Fill::Solid(override_color.unwrap_or(
warp_drive_icon_color(
appearance,
DriveObjectType::Notebook {
is_ai_document: false,
},
),
)))
.finish(),
};
let icon = ConstrainedBox::new(icon_element)
.with_height(IMPORT_FONT_SIZE)
.with_width(IMPORT_FONT_SIZE)
.finish();
let mut item_row = Flex::row()
.with_child(status_icon)
.with_child(Container::new(icon).with_margin_right(4.).finish())
.with_child(
appearance
.ui_builder()
.span(self.name.clone())
.with_style(UiComponentStyles {
font_size: Some(IMPORT_FONT_SIZE),
font_color: override_color,
..Default::default()
})
.build()
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
let total_indent = BASE_INDENT + INDENT_MARGIN * indent_level as f32;
match &self.status {
UploadStatus::Error(e) => {
item_row.add_child(
Shrinkable::new(
1.,
Align::new(
Container::new(
appearance
.ui_builder()
.retry_button(16., self.refresh_button_mouse_state.clone())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ImportModalBodyAction::RetryFile(
file_id,
))
})
.finish(),
)
.with_margin_right(4.)
.with_margin_left(4.)
.finish(),
)
.right()
.finish(),
)
.finish(),
);
let error_row = appearance
.ui_builder()
.span(e.to_string())
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().ui_error_color()),
..Default::default()
})
.build()
.finish();
Container::new(
Flex::column()
.with_child(item_row.finish())
.with_child(
Container::new(error_row)
.with_margin_top(4.)
.with_margin_left(INDENT_MARGIN)
.finish(),
)
.finish(),
)
.with_margin_left(total_indent)
.with_padding_top(10.)
.with_padding_bottom(10.)
.finish()
}
UploadStatus::Loaded(server_id) if allow_click_to_open_target => {
render_highlighted_pill(
item_row.finish(),
total_indent,
self.open_button_mouse_state.clone(),
server_id.to_owned(),
appearance,
)
}
_ => Container::new(item_row.finish())
.with_margin_left(total_indent)
.with_padding_top(10.)
.with_padding_bottom(10.)
.finish(),
}
}
}
pub(super) async fn expand_dirs(dirs: Vec<PathBuf>) -> FileUploadState {
let mut next_folder_id = FolderId::from(0);
let mut next_file_id = FileId::from(0);
let mut folder_id_to_node = HashMap::new();
let mut file_id_to_node = HashMap::new();
let current_folder_id = next_folder_id;
let mut dummy_root_node = FolderNode::new(String::new(), current_folder_id);
next_folder_id += 1;
for child_path in dirs {
if let Some(child_node) = ImportedNode::initiate_from_path(
child_path,
current_folder_id,
&mut next_folder_id,
&mut next_file_id,
&mut folder_id_to_node,
&mut file_id_to_node,
)
.await
{
dummy_root_node.children.push(child_node);
}
}
folder_id_to_node.insert(current_folder_id, dummy_root_node);
FileUploadState::new(folder_id_to_node, file_id_to_node)
}
pub(super) struct FileUploadState {
pub(super) folder_id_to_node: HashMap<FolderId, FolderNode>,
pub(super) file_id_to_node: HashMap<FileId, FileNode>,
}
impl FileUploadState {
fn new(
folder_id_to_node: HashMap<FolderId, FolderNode>,
file_id_to_node: HashMap<FileId, FileNode>,
) -> Self {
Self {
folder_id_to_node,
file_id_to_node,
}
}
#[cfg(test)]
pub(super) fn debug_print(&self) -> String {
ImportedNode::Folder(FolderId::root_id())
.debug_print(&self.folder_id_to_node, &self.file_id_to_node)
}
// Get the cloud id for the provided folder id. Returns None if the folder
// is the root node folder.
pub(super) fn folder_cloud_id(&self, folder_id: FolderId) -> Option<ClientId> {
if folder_id == FolderId::root_id() {
// Root folder should not have a client id.
return None;
}
match self.folder_id_to_node.get(&folder_id) {
Some(folder_node) => Some(folder_node.cloud_id()),
None => {
log::error!("Provided folder id should exist");
None
}
}
}
pub(super) fn file_name_and_parent_cloud_id(
&self,
file_id: FileId,
) -> Option<(String, Option<ClientId>)> {
let file_node = self.file_id_to_node.get(&file_id)?;
let parent_cloud_id = self.folder_cloud_id(file_node.parent_id);
Some((file_node.name.clone(), parent_cloud_id))
}
pub(super) fn mark_folder_synced(&mut self, result: UploadResult, folder_id: FolderId) {
let parent_id = if let Some(folder) = self.folder_id_to_node.get_mut(&folder_id) {
let should_update_upstream_folders = match result {
// If uploading the folder is not successful, its children will not upload.
// Mark the folder as errored and update upstream folders.
UploadResult::Error(e) => {
folder.status = UploadStatus::Error(e);
true
}
// If the folder has no children, mark the folder as completed and update
// upstream folders.
UploadResult::Success(server_id) => {
folder.server_id = Some(server_id.clone());
// If a folder has no children or all of its children complete syncing,
// we need to bubble the state up in the folder hierachy tree.
if folder.children().is_empty() || folder.all_children_synced {
folder.status = UploadStatus::Loaded(server_id);
true
} else {
false
}
}
};
if should_update_upstream_folders {
Some(folder.parent_id)
} else {
None
}
} else {
None
};
if let Some(parent_id) = parent_id {
self.update_upstream_folders_loaded(parent_id);
}
}
pub(super) fn set_file_and_parent_to_loading(&mut self, file_id: FileId) {
let parent_id = match self.file_id_to_node.get_mut(&file_id) {
Some(file_node) => {
file_node.status = UploadStatus::Loading;
Some(file_node.parent_id)
}
None => None,
};
if let Some(parent_id) = parent_id {
self.update_upstream_folders_loading(parent_id);
}
}
/// File upload is completed if the root node is marked as complete.
pub(super) fn is_complete(&self) -> bool {
self.folder_id_to_node
.get(&FolderId::root_id())
.expect("Root node should exist")
.status
.is_loaded()
}
pub(super) fn all_files_saved_locally(&self) -> bool {
self.folder_id_to_node
.get(&FolderId::root_id())
.expect("Root node should exist")
.are_children_saved_locally(&self.folder_id_to_node, &self.file_id_to_node)
}
/// This recursively updates the upstream folder to be loading.
fn update_upstream_folders_loading(&mut self, parent_folder_id: FolderId) {
let mut next_folder_to_update = parent_folder_id;
while let Some(folder_node) = self.folder_id_to_node.get(&next_folder_to_update) {
let parent_node = folder_node.parent_id;
if folder_node.status.is_loaded() {
let folder_node = self
.folder_id_to_node
.get_mut(&next_folder_to_update)
.expect("Should exist");
folder_node.all_children_synced = false;
folder_node.status = UploadStatus::Loading
} else {
break;
}
if next_folder_to_update == FolderId::root_id() {
break;
}
next_folder_to_update = parent_node;
}
}
/// This recursively updates the upstream folder if it is completed.
fn update_upstream_folders_loaded(&mut self, parent_folder_id: FolderId) {
let mut next_folder_to_update = parent_folder_id;
while let Some(folder_node) = self.folder_id_to_node.get(&next_folder_to_update) {
let parent_node = folder_node.parent_id;
let folder_is_root_node = next_folder_to_update == FolderId::root_id();
if !folder_node.status.is_loaded()
&& folder_node.are_children_loaded(&self.folder_id_to_node, &self.file_id_to_node)
{
let folder_node = self
.folder_id_to_node
.get_mut(&next_folder_to_update)
.expect("Should exist");
folder_node.all_children_synced = true;
if let Some(id) = &folder_node.server_id {
folder_node.status = UploadStatus::Loaded(id.clone());
} else if folder_is_root_node {
folder_node.status = UploadStatus::Loaded(String::new());
} else {
// Normally a folder should have been uploaded for a file to be uploaded.
// However, in the rare case when a file errors out when parsing and it happens
// to be the last file in the folder, we could get into a situation where the
// the folder is not uploaded but all of its children complete syncing.
break;
}
} else {
break;
}
if folder_is_root_node {
break;
}
next_folder_to_update = parent_node;
}
}
pub(super) fn update_tree_with_file_upload_result(
&mut self,
result: UploadResult,
file_id: FileId,
) -> bool {
let Some(file_node_to_update) = self.file_id_to_node.get_mut(&file_id) else {
return false;
};
file_node_to_update.status = match result {
UploadResult::Success(id) => UploadStatus::Loaded(id),
UploadResult::Error(e) => UploadStatus::Error(format!("Failed to parse file: {e}")),
};
let parent_id = file_node_to_update.parent_id;
self.update_upstream_folders_loaded(parent_id);
true
}
}
pub(super) async fn parse_file(path: PathBuf, file_type: FileType) -> Result<FileContent> {
match file_type {
FileType::Notebook => Ok(FileContent::Notebook(post_process_notebook(
&async_fs::read_to_string(path).await?,
))),
FileType::Workflow => {
let file = async_fs::read(path).await?;
let mut workflow_enums: HashMap<ClientId, WorkflowEnum> = HashMap::new();
let mut workflows = vec![];
for document in serde_yaml::Deserializer::from_slice(&file) {
let (workflow, new_enums) = export_deserialize(document)?;
workflows.push(workflow);
workflow_enums.extend(new_enums);
}
Ok(FileContent::Workflow {
workflows,
workflow_enums,
})
}
}
}
pub(super) enum UploadResult {
Success(String),
Error(String),
}
fn render_highlighted_pill(
row: Box<dyn Element>,
total_indent: f32,
mouse_state_handle: MouseStateHandle,
server_id: String,
appearance: &Appearance,
) -> Box<dyn Element> {
let inner = Container::new(row)
.with_margin_left(total_indent)
.with_padding_top(5.)
.with_padding_bottom(5.)
.finish();
Container::new(
Hoverable::new(mouse_state_handle, |state| {
if state.is_hovered() || state.is_clicked() {
Container::new(inner)
.with_background(appearance.theme().surface_3())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
} else {
inner
}
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ImportModalBodyAction::ClickedToOpenTarget(
server_id.clone(),
))
})
.finish(),
)
.with_padding_top(5.)
.with_padding_bottom(5.)
.finish()
}
+328
View File
@@ -0,0 +1,328 @@
use std::collections::HashMap;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::{
cloud_object::{model::persistence::CloudModel, CloudObjectEventEntrypoint, Owner},
drive::folders::FolderId,
notebooks::CloudNotebookModel,
server::{
cloud_objects::update_manager::{
InitiatedBy, ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
},
ids::{ClientId, SyncId},
},
workflows::{workflow::Workflow, workflow_enum::WorkflowEnum},
};
use super::nodes::{self, FileId};
pub(super) enum ImportQueueEvent {
FileCompleted {
file_id: FileId,
server_id: Option<String>,
},
FolderCompleted {
folder_id: nodes::FolderId,
server_id: Option<String>,
},
FileSavedLocally(FileId),
}
#[derive(Debug)]
pub(super) enum ParentId {
FolderToUpload(ClientId),
InitialFolder(Option<SyncId>),
}
#[derive(Debug)]
pub(super) struct ImportQueueArgs {
pub(super) owner: Owner,
pub(super) parent_id: ParentId,
pub(super) content: RequestContent,
}
#[derive(Debug)]
pub(super) enum RequestContent {
Folder {
name: String,
client_id: ClientId,
folder_id: nodes::FolderId,
},
Notebook {
title: String,
data: String,
client_id: ClientId,
file_id: FileId,
},
Workflow {
workflows: Vec<(Workflow, ClientId)>,
workflow_enums: HashMap<ClientId, WorkflowEnum>,
file_id: FileId,
},
}
#[derive(Default)]
struct FileCompletionCounter {
client_id_to_file_id: HashMap<ClientId, FileId>,
file_id_to_counter: HashMap<FileId, usize>,
}
impl FileCompletionCounter {
fn request_completed(&mut self, client_id: ClientId) -> Option<FileId> {
if let Some(file_id) = self.client_id_to_file_id.get(&client_id) {
let completed = match self.file_id_to_counter.get_mut(file_id) {
Some(counter) => {
*counter = counter.saturating_sub(1);
*counter == 0
}
None => {
log::error!("File completion counter should exist but it doesn't");
false
}
};
if completed {
return Some(*file_id);
}
}
None
}
fn add_entry(&mut self, client_id: ClientId, file_id: FileId) {
self.client_id_to_file_id.insert(client_id, file_id);
*self.file_id_to_counter.entry(file_id).or_insert(0) += 1;
}
}
pub(super) struct ImportQueue {
queue: Vec<ImportQueueArgs>,
client_to_server_id: HashMap<ClientId, Option<FolderId>>,
client_to_node_folder_id: HashMap<ClientId, nodes::FolderId>,
file_completion: FileCompletionCounter,
}
impl ImportQueue {
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);
});
Self {
queue: Vec::new(),
client_to_server_id: HashMap::default(),
file_completion: Default::default(),
client_to_node_folder_id: HashMap::default(),
}
}
// Whether all dependcies of an item has been sync-ed.
fn dependency_synced(&self, item: &ImportQueueArgs) -> bool {
match &item.parent_id {
ParentId::FolderToUpload(id) => self
.client_to_server_id
.get(id)
.map(|item| item.is_some())
.unwrap_or(false),
ParentId::InitialFolder(_) => true,
}
}
// Enqueue a new request to the import queue.
pub fn enqueue(&mut self, arg: ImportQueueArgs, ctx: &mut ModelContext<Self>) {
// Update internal tracker of the object.
match &arg.content {
RequestContent::Folder {
client_id,
folder_id,
..
} => {
self.client_to_server_id.insert(*client_id, None);
self.client_to_node_folder_id.insert(*client_id, *folder_id);
}
RequestContent::Notebook {
client_id, file_id, ..
} => self.file_completion.add_entry(*client_id, *file_id),
RequestContent::Workflow {
workflows, file_id, ..
} => {
for (_, client_id) in workflows {
self.file_completion.add_entry(*client_id, *file_id);
}
}
}
self.queue.push(arg);
self.dequeue(ctx);
}
// Dequeue a new request from the import queue.
pub fn dequeue(&mut self, ctx: &mut ModelContext<Self>) {
if self.queue.is_empty() {
return;
}
if let Some(idx) = self
.queue
.iter()
.position(|item| self.dependency_synced(item))
{
let dequeued_item = self.queue.remove(idx);
let parent_id = match dequeued_item.parent_id {
ParentId::FolderToUpload(client_id) => Some(SyncId::ServerId(
self.client_to_server_id
.get(&client_id)
.expect("Client id entry should exist")
.expect("Server id entry should exist")
.into(),
)),
ParentId::InitialFolder(folder_id) => folder_id,
};
match dequeued_item.content {
RequestContent::Folder {
name, client_id, ..
} => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_folder(
name,
dequeued_item.owner,
client_id,
parent_id,
false,
InitiatedBy::User,
ctx,
);
});
}
RequestContent::Notebook {
title,
data,
client_id,
file_id,
} => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_notebook(
client_id,
dequeued_item.owner,
parent_id,
CloudNotebookModel {
title,
data,
ai_document_id: None,
conversation_id: None,
},
CloudObjectEventEntrypoint::ImportModal,
false,
ctx,
);
});
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
}
RequestContent::Workflow {
workflows,
workflow_enums,
file_id,
} => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
// Create any new workflow enums
for (client_id, workflow_enum) in workflow_enums {
update_manager.create_workflow_enum(
workflow_enum,
dequeued_item.owner,
client_id,
CloudObjectEventEntrypoint::ImportModal,
false,
ctx,
);
}
// Create the workflow
for (workflow, client_id) in workflows {
update_manager.create_workflow(
workflow,
dequeued_item.owner,
parent_id,
client_id,
CloudObjectEventEntrypoint::ImportModal,
false,
ctx,
);
}
});
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
}
}
self.dequeue(ctx);
}
}
fn handle_update_manager_event(
&mut self,
event: &UpdateManagerEvent,
ctx: &mut ModelContext<Self>,
) {
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
return;
};
if matches!(&result.operation, ObjectOperation::Create { .. }) {
let Some(client_id) = result.client_id else {
return;
};
let is_successful = matches!(&result.success_type, OperationSuccessType::Success);
let server_id = result.server_id;
if let Some(file_id) = self.file_completion.request_completed(client_id) {
ctx.emit(ImportQueueEvent::FileCompleted {
file_id,
server_id: server_id.map(|server_id| server_id.uid()),
});
return;
}
// Return early if we are not successfully uploading a folder.
if !is_successful {
if let Some(node_id) = self.client_to_node_folder_id.get(&client_id) {
ctx.emit(ImportQueueEvent::FolderCompleted {
folder_id: *node_id,
server_id: server_id.map(|server_id| server_id.uid()),
});
}
return;
}
let cloud_model = CloudModel::as_ref(ctx);
let Some(folder_id) = cloud_model
.get_folder_by_uid(&result.server_id.expect("Expect id").uid())
.and_then(|folder| folder.id.into_server())
else {
return;
};
let replaced = match self.client_to_server_id.get_mut(&client_id) {
Some(value) if value.is_none() => {
*value = Some(folder_id.into());
true
}
_ => false,
};
if replaced {
if let Some(node_id) = self.client_to_node_folder_id.get(&client_id) {
ctx.emit(ImportQueueEvent::FolderCompleted {
folder_id: *node_id,
server_id: server_id.map(|server_id| server_id.uid()),
});
}
self.dequeue(ctx);
}
}
}
}
impl Entity for ImportQueue {
type Event = ImportQueueEvent;
}
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
use warp_core::ui::appearance::Appearance;
use warp_server_client::cloud_object::ServerPermissions;
use warpui::{
platform::WindowStyle, AddSingletonModel, App, SingletonEntity, TypedActionView, ViewHandle,
};
use crate::{
ai::blocklist::BlocklistAIHistoryModel,
auth::{auth_manager::AuthManager, AuthStateProvider},
cloud_object::{
model::{actions::ObjectActions, persistence::CloudModel, view::CloudViewModel},
CloudObjectSyncStatus, ObjectIdType, ObjectType, Owner, ServerCreationInfo, Space,
},
drive::{items::WarpDriveItemId, CloudObjectTypeAndId},
menu::MenuItem,
network::NetworkStatus,
notebooks::{CloudNotebook, CloudNotebookModel},
server::{
cloud_objects::update_manager::UpdateManager,
ids::{ClientId, ServerIdAndType, SyncId},
server_api::ServerApiProvider,
sync_queue::{QueueItem, SyncQueue},
telemetry::context_provider::AppTelemetryContextProvider,
},
settings_view::keybindings::KeybindingChangedNotifier,
terminal::shared_session::permissions_manager::SessionPermissionsManager,
test_util::settings::initialize_settings_for_tests,
workflows::{workflow::Workflow, CloudWorkflow, CloudWorkflowModel},
workspaces::{
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
},
Assets,
};
use super::{DriveIndex, DriveIndexAction};
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
app.add_singleton_model(AuthManager::new_for_test);
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudViewModel::mock);
app.add_singleton_model(|_| ObjectActions::new(Vec::new()));
app.add_singleton_model(|_| UserProfiles::new(Vec::new()));
app.add_singleton_model(SessionPermissionsManager::new);
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
#[cfg(feature = "voice_input")]
app.add_singleton_model(voice_input::VoiceInput::new);
}
fn create_index(app: &mut App) -> ViewHandle<DriveIndex> {
let (_, index) = app.add_window(WindowStyle::NotStealFocus, DriveIndex::new);
index
}
fn create_workflow(app: &mut App) -> SyncId {
CloudModel::handle(app).update(app, |cloud_model, ctx| {
let client_id = ClientId::new();
let sync_id = SyncId::ClientId(client_id);
let workflow = Workflow::new("my workflow", "my command");
cloud_model.create_object(
sync_id,
CloudWorkflow::new_local(
CloudWorkflowModel::new(workflow),
Owner::mock_current_user(),
None,
client_id,
),
ctx,
);
sync_id
})
}
fn create_notebook(app: &mut App) -> SyncId {
CloudModel::handle(app).update(app, |cloud_model, ctx| {
let client_id = ClientId::new();
let sync_id = SyncId::ClientId(client_id);
cloud_model.create_object(
sync_id,
CloudNotebook::new_local(
CloudNotebookModel::default(),
Owner::mock_current_user(),
None,
client_id,
),
ctx,
);
sync_id
})
}
fn set_object_in_error(app: &mut App, cloud_object_type_and_id: &CloudObjectTypeAndId) {
CloudModel::handle(app).update(
app,
|cloud_model, _ctx: &mut warpui::ModelContext<'_, CloudModel>| {
if let Some(object) = cloud_model.get_mut_by_uid(&cloud_object_type_and_id.uid()) {
object.set_pending_content_changes_status(CloudObjectSyncStatus::Errored);
}
},
);
}
fn label_for_menu_item(item: &MenuItem<DriveIndexAction>) -> &str {
if let MenuItem::Item(item) = item {
item.label()
} else {
panic!("item provided wasn't of type MenuItem::Item")
}
}
#[test]
fn test_retry_menu_item_visibility() {
App::test(Assets, |mut app| async move {
initialize_app(&mut app);
let index = create_index(&mut app);
let sync_id = create_workflow(&mut app);
let cloud_object_type_and_id: CloudObjectTypeAndId =
CloudObjectTypeAndId::from_id_and_type(sync_id, ObjectType::Workflow);
let warp_drive_item_id = WarpDriveItemId::Object(cloud_object_type_and_id);
// by default, it doesn't show up
index.update(&mut app, |index, ctx| {
let menu_items = index.menu_items(&Space::Personal, &warp_drive_item_id, ctx);
assert_eq!(menu_items.len(), 5);
assert_eq!(label_for_menu_item(&menu_items[0]), "Edit");
assert_eq!(label_for_menu_item(&menu_items[1]), "Copy workflow text");
assert_eq!(label_for_menu_item(&menu_items[2]), "Share");
assert_eq!(label_for_menu_item(&menu_items[3]), "Duplicate");
assert_eq!(label_for_menu_item(&menu_items[4]), "Export");
});
// when the object is in error, it should show up
set_object_in_error(&mut app, &cloud_object_type_and_id);
index.update(&mut app, |index, ctx| {
let menu_items = index.menu_items(&Space::Personal, &warp_drive_item_id, ctx);
assert_eq!(menu_items.len(), 6);
assert_eq!(label_for_menu_item(&menu_items[0]), "Retry");
assert_eq!(label_for_menu_item(&menu_items[1]), "Edit");
assert_eq!(label_for_menu_item(&menu_items[2]), "Copy workflow text");
assert_eq!(label_for_menu_item(&menu_items[3]), "Share");
assert_eq!(label_for_menu_item(&menu_items[4]), "Duplicate");
assert_eq!(label_for_menu_item(&menu_items[5]), "Export");
});
// but if we're offline, it shouldn't show up
NetworkStatus::handle(&app).update(&mut app, |network_status, ctx| {
network_status.reachability_changed(false, ctx);
});
index.update(&mut app, |index, ctx| {
let menu_items = index.menu_items(&Space::Personal, &warp_drive_item_id, ctx);
assert_eq!(menu_items.len(), 5);
assert_eq!(label_for_menu_item(&menu_items[0]), "Edit");
assert_eq!(label_for_menu_item(&menu_items[1]), "Copy workflow text");
assert_eq!(label_for_menu_item(&menu_items[2]), "Share");
assert_eq!(label_for_menu_item(&menu_items[3]), "Duplicate");
assert_eq!(label_for_menu_item(&menu_items[4]), "Export");
});
})
}
#[test]
fn test_retry_menu_item_logic() {
App::test(Assets, |mut app| async move {
initialize_app(&mut app);
let index = create_index(&mut app);
let sync_id = create_workflow(&mut app);
let cloud_object_type_and_id: CloudObjectTypeAndId =
CloudObjectTypeAndId::from_id_and_type(sync_id, ObjectType::Workflow);
SyncQueue::handle(&app).update(&mut app, |sync_queue, _ctx| {
sync_queue.stop_dequeueing();
assert_eq!(sync_queue.queue().len(), 0);
});
index.update(&mut app, |index, ctx| {
index.retry_failed_object(&cloud_object_type_and_id, ctx);
});
// the item is now in flight
CloudModel::handle(&app).update(&mut app, |cloud_model, _ctx| {
if let Some(object) = cloud_model.get_mut_by_uid(&cloud_object_type_and_id.uid()) {
assert!(object.metadata().has_pending_content_changes());
}
});
// with an object not known to the server, we enqueue a CreateWorkflow item
SyncQueue::handle(&app).read(&app, |sync_queue, _ctx| {
assert_eq!(sync_queue.queue().len(), 1);
assert!(matches!(
sync_queue.queue()[0].1,
QueueItem::CreateWorkflow { .. }
))
});
let new_sync_id: SyncId = SyncId::ServerId(1.into());
// make the object known to the server (by giving it a server id instead)
CloudModel::handle(&app).update(&mut app, |cloud_model, ctx| {
if let CloudObjectTypeAndId::Workflow(SyncId::ClientId(client_id)) =
cloud_object_type_and_id
{
if let SyncId::ServerId(server_id) = new_sync_id {
let server_creation_info = ServerCreationInfo {
server_id_and_type: ServerIdAndType {
id: server_id,
id_type: ObjectIdType::Workflow,
},
creator_uid: None,
permissions: ServerPermissions::mock_personal(),
};
cloud_model.update_object_after_server_creation(
client_id,
server_creation_info,
ctx,
);
}
}
});
index.update(&mut app, |index, ctx| {
let new_cloud_object_type_and_id: CloudObjectTypeAndId =
CloudObjectTypeAndId::from_id_and_type(new_sync_id, ObjectType::Workflow);
index.retry_failed_object(&new_cloud_object_type_and_id, ctx);
});
// with an object known to the server, we enqueue an UpdateWorkflow item
SyncQueue::handle(&app).read(&app, |sync_queue, _ctx| {
assert_eq!(sync_queue.queue().len(), 2);
assert!(matches!(
sync_queue.queue()[1].1,
QueueItem::UpdateWorkflow { .. }
))
});
})
}
#[test]
fn test_warp_drive_navigation_states() {
use crate::drive::index::DriveIndexAction;
App::test((), |mut app| async move {
initialize_app(&mut app);
let index = create_index(&mut app);
let sync_id = create_notebook(&mut app);
let cloud_object_type_and_id: CloudObjectTypeAndId =
CloudObjectTypeAndId::from_id_and_type(sync_id, ObjectType::Notebook);
index.read(&app, |index, _| {
assert_eq!(index.selected, None, "Expect selected to be None");
assert_eq!(
index.focused_index,
Some(0),
"Expect focused_index to be initialized"
);
});
index.update(&mut app, |index, ctx| {
index.handle_action(&DriveIndexAction::OpenObject(cloud_object_type_and_id), ctx);
});
index.read(&app, |index, _| {
assert_eq!(
index.selected,
Some(WarpDriveItemId::Object(cloud_object_type_and_id)),
"Expect selected to have correct value"
);
});
});
}
+115
View File
@@ -0,0 +1,115 @@
use warpui::{
elements::{Container, Flex, MouseStateHandle, ParentElement},
fonts::Weight,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element,
};
use crate::{
ai::facts::{AIFact, AIMemory, CloudAIFact},
appearance::Appearance,
cloud_object::CloudObjectMetadata,
drive::{index::DriveIndexAction, CloudObjectTypeAndId, DriveObjectType},
themes::theme::Fill,
};
use super::{WarpDriveItem, WarpDriveItemId};
#[derive(Clone)]
pub struct WarpDriveAIFact {
id: CloudObjectTypeAndId,
ai_fact: CloudAIFact,
}
impl WarpDriveAIFact {
pub fn new(id: CloudObjectTypeAndId, ai_fact: CloudAIFact) -> Self {
Self { id, ai_fact }
}
}
impl WarpDriveItem for WarpDriveAIFact {
fn display_name(&self) -> Option<String> {
match &self.ai_fact.model().string_model {
AIFact::Memory(AIMemory { content, name, .. }) => {
if let Some(name) = name {
if !name.is_empty() {
Some(name.clone())
} else {
Some(content.clone())
}
} else {
Some(content.clone())
}
}
}
}
fn metadata(&self) -> Option<&CloudObjectMetadata> {
Some(&self.ai_fact.metadata)
}
fn object_type(&self) -> Option<DriveObjectType> {
Some(DriveObjectType::AIFact)
}
fn secondary_icon(&self, _color: Option<Fill>) -> Option<Box<dyn Element>> {
None
}
fn click_action(&self) -> Option<DriveIndexAction> {
Some(DriveIndexAction::OpenAIFactCollection)
}
fn preview(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
let title_to_render = match &self.ai_fact.model().string_model {
AIFact::Memory(AIMemory { content, .. }) => content.clone(),
};
let title = appearance
.ui_builder()
.wrappable_text(title_to_render, true)
.with_style(UiComponentStyles {
font_color: Some(
appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
),
font_size: Some(14.),
font_weight: Some(Weight::Bold),
..Default::default()
})
.build()
.finish();
Some(
Flex::column()
.with_child(Container::new(title).finish())
.finish(),
)
}
fn warp_drive_id(&self) -> WarpDriveItemId {
WarpDriveItemId::Object(self.id)
}
fn sync_status_icon(
&self,
sync_queue_is_dequeueing: bool,
hover_state: MouseStateHandle,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
self.ai_fact.metadata.pending_changes_statuses.render_icon(
sync_queue_is_dequeueing,
hover_state,
appearance,
)
}
fn action_summary(&self, _app: &AppContext) -> Option<String> {
None
}
fn clone_box(&self) -> Box<dyn WarpDriveItem> {
Box::new(self.clone())
}
}
+73
View File
@@ -0,0 +1,73 @@
use warpui::{elements::MouseStateHandle, AppContext, Element};
use crate::{
appearance::Appearance,
cloud_object::CloudObjectMetadata,
drive::{index::DriveIndexAction, DriveObjectType},
server::ids::ClientId,
themes::theme::Fill,
};
use super::{WarpDriveItem, WarpDriveItemId};
#[derive(Clone)]
pub struct WarpDriveAIFactCollection {
id: ClientId,
}
impl WarpDriveAIFactCollection {
pub fn new(id: ClientId) -> Self {
Self { id }
}
pub fn id(&self) -> ClientId {
self.id
}
}
impl WarpDriveItem for WarpDriveAIFactCollection {
fn display_name(&self) -> Option<String> {
Some("Rules".to_string())
}
fn metadata(&self) -> Option<&CloudObjectMetadata> {
None
}
fn object_type(&self) -> Option<DriveObjectType> {
Some(DriveObjectType::AIFactCollection)
}
fn secondary_icon(&self, _color: Option<Fill>) -> Option<Box<dyn Element>> {
None
}
fn click_action(&self) -> Option<DriveIndexAction> {
Some(DriveIndexAction::OpenAIFactCollection)
}
fn preview(&self, _appearance: &Appearance) -> Option<Box<dyn Element>> {
None
}
fn warp_drive_id(&self) -> WarpDriveItemId {
WarpDriveItemId::AIFactCollection
}
fn sync_status_icon(
&self,
_sync_queue_is_dequeueing: bool,
_hover_state: MouseStateHandle,
_appearance: &Appearance,
) -> Option<Box<dyn Element>> {
None
}
fn action_summary(&self, _app: &AppContext) -> Option<String> {
None
}
fn clone_box(&self) -> Box<dyn WarpDriveItem> {
Box::new(self.clone())
}
}
+182
View File
@@ -0,0 +1,182 @@
use itertools::Itertools;
use warp_core::context_flag::ContextFlag;
use warpui::{
elements::{Clipped, Container, Flex, MouseStateHandle, ParentElement},
fonts::Weight,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, SingletonEntity,
};
use crate::{
appearance::Appearance,
cloud_object::{
model::actions::{ObjectActionType, ObjectActions},
CloudObjectMetadata,
},
drive::{index::DriveIndexAction, CloudObjectTypeAndId, DriveObjectType},
env_vars::{CloudEnvVarCollection, EnvVarValue},
themes::theme::Fill,
};
use super::{WarpDriveItem, WarpDriveItemId};
#[derive(Clone)]
pub struct WarpDriveEnvVarCollection {
id: CloudObjectTypeAndId,
env_var_collection: CloudEnvVarCollection,
}
impl WarpDriveEnvVarCollection {
pub fn new(id: CloudObjectTypeAndId, env_var_collection: CloudEnvVarCollection) -> Self {
Self {
id,
env_var_collection,
}
}
}
impl WarpDriveItem for WarpDriveEnvVarCollection {
fn display_name(&self) -> Option<String> {
self.env_var_collection.model().string_model.title.clone()
}
fn metadata(&self) -> Option<&CloudObjectMetadata> {
Some(&self.env_var_collection.metadata)
}
fn object_type(&self) -> Option<DriveObjectType> {
Some(DriveObjectType::EnvVarCollection)
}
fn secondary_icon(&self, _color: Option<Fill>) -> Option<Box<dyn Element>> {
None
}
fn click_action(&self) -> Option<DriveIndexAction> {
// If running the workflow is disabled (true for some web views),
// we should just open the workflow instead.
if !ContextFlag::RunWorkflow.is_enabled() {
Some(DriveIndexAction::OpenObject(self.id))
} else {
Some(DriveIndexAction::RunObject(self.id))
}
}
fn preview(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
let title_text = self.env_var_collection.model().string_model.title.clone();
let title_to_render = if let Some(title) = title_text {
title
} else {
"Untitled".to_string()
};
let title = appearance
.ui_builder()
.wrappable_text(title_to_render, true)
.with_style(UiComponentStyles {
font_color: Some(
appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
),
font_size: Some(14.),
font_weight: Some(Weight::Bold),
..Default::default()
})
.build()
.finish();
let mut text = Flex::column().with_child(Container::new(title).finish());
if let Some(description) = self
.env_var_collection
.model()
.string_model
.description
.clone()
{
let description_text = appearance
.ui_builder()
.paragraph(description.clone())
.with_style(UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
.into(),
),
font_size: Some(12.),
..Default::default()
});
text.add_child(
Container::new(description_text.build().finish())
.with_margin_top(4.)
.finish(),
)
}
let rows = self
.env_var_collection
.model()
.string_model
.vars
.iter()
.map(|var| {
Clipped::new(
appearance
.ui_builder()
.label(match &var.value {
EnvVarValue::Constant(val) => format!("{}: {}", var.name, val),
EnvVarValue::Command(cmd) => format!("{}: {}", var.name, cmd.name),
EnvVarValue::Secret(sec) => {
format!("{}: {}", var.name, sec.get_display_name())
}
})
.with_style(UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(12.),
..Default::default()
})
.build()
.finish(),
)
.finish()
})
.collect_vec();
text.add_child(
Container::new(Flex::column().with_children(rows).finish())
.with_margin_top(8.)
.finish(),
);
Some(text.finish())
}
fn warp_drive_id(&self) -> WarpDriveItemId {
WarpDriveItemId::Object(self.id)
}
fn sync_status_icon(
&self,
sync_queue_is_dequeueing: bool,
hover_state: MouseStateHandle,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
self.env_var_collection
.metadata
.pending_changes_statuses
.render_icon(sync_queue_is_dequeueing, hover_state, appearance)
}
fn action_summary(&self, app: &AppContext) -> Option<String> {
ObjectActions::as_ref(app)
.get_action_history_summary_for_action_type(&self.id.uid(), ObjectActionType::Execute)
}
fn clone_box(&self) -> Box<dyn WarpDriveItem> {
Box::new(self.clone())
}
}
+98
View File
@@ -0,0 +1,98 @@
use warp_core::features::FeatureFlag;
use warpui::{elements::MouseStateHandle, AppContext, Element};
use crate::{
appearance::Appearance,
cloud_object::CloudObjectMetadata,
drive::{
cloud_object_styling::warp_drive_icon_color, folders::CloudFolder, index::DriveIndexAction,
CloudObjectTypeAndId, DriveObjectType,
},
themes::theme::Fill,
ui_components::icons::Icon,
};
use super::{WarpDriveItem, WarpDriveItemId};
#[derive(Clone)]
pub struct WarpDriveFolder {
id: CloudObjectTypeAndId,
folder: CloudFolder,
}
impl WarpDriveFolder {
pub fn new(id: CloudObjectTypeAndId, folder: CloudFolder) -> Self {
Self { id, folder }
}
}
impl WarpDriveItem for WarpDriveFolder {
fn display_name(&self) -> Option<String> {
if self.folder.model().name.is_empty() {
None
} else {
Some(self.folder.model().name.clone())
}
}
fn metadata(&self) -> Option<&CloudObjectMetadata> {
Some(&self.folder.metadata)
}
fn object_type(&self) -> Option<DriveObjectType> {
Some(DriveObjectType::Folder)
}
fn icon(&self, appearance: &Appearance, color: Option<Fill>) -> Option<Box<dyn Element>> {
let icon_fill =
color.unwrap_or(warp_drive_icon_color(appearance, DriveObjectType::Folder).into());
let icon = if FeatureFlag::WarpPacks.is_enabled() && self.folder.model().is_warp_pack {
Icon::PackageCheck
} else {
Icon::from(DriveObjectType::Folder)
};
Some(icon.to_warpui_icon(icon_fill).finish())
}
fn secondary_icon(&self, _color: Option<Fill>) -> Option<Box<dyn Element>> {
None
}
fn is_folder_open(&self) -> Option<bool> {
Some(self.folder.model().is_open)
}
fn click_action(&self) -> Option<DriveIndexAction> {
Some(DriveIndexAction::ToggleFolderOpen(self.folder.id))
}
fn preview(&self, _: &Appearance) -> Option<Box<dyn Element>> {
None
}
fn warp_drive_id(&self) -> WarpDriveItemId {
WarpDriveItemId::Object(self.id)
}
fn sync_status_icon(
&self,
sync_queue_is_dequeueing: bool,
hover_state: MouseStateHandle,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
self.folder.metadata.pending_changes_statuses.render_icon(
sync_queue_is_dequeueing,
hover_state,
appearance,
)
}
fn action_summary(&self, _app: &AppContext) -> Option<String> {
None
}
fn clone_box(&self) -> Box<dyn WarpDriveItem> {
Box::new(self.clone())
}
}
+944
View File
@@ -0,0 +1,944 @@
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
use warpui::{
elements::{
AcceptedByDropTarget, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Draggable, DraggableState, DropShadow, Empty, Flex, Hoverable,
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, SavePosition, Shrinkable, SizeConstraintCondition,
SizeConstraintSwitch, Stack,
},
fonts::Weight,
platform::Cursor,
presenter::PositionCache,
ui_components::{
components::{UiComponent, UiComponentStyles},
text::Span,
},
AppContext, Element, SingletonEntity, ViewHandle,
};
use crate::{
cloud_object::{
model::{persistence::CloudModel, view::CloudViewModel},
CloudObject, CloudObjectMetadataExt, Owner,
},
drive::CloudObjectTypeAndId,
workspaces::{user_profiles::UserProfiles, user_workspaces::UserWorkspaces},
};
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
use crate::workspace::tab_settings::TabSettings;
use crate::{
appearance::Appearance,
cloud_object::Space,
drive::{
index::{
DriveIndexAction, AUTOSCROLL_DETECTION_DISTANCE, AUTOSCROLL_SPEED_MULTIPLIER,
DRIVE_INDEX_VIEW_POSITION_ID, FOLDER_DEPTH_INDENT, INDEX_CONTENT_MARGIN_LEFT,
ITEM_FONT_SIZE, ITEM_MARGIN_BOTTOM, ITEM_PADDING_HORIZONTAL, ITEM_PADDING_VERTICAL,
},
panel::WARP_DRIVE_POSITION_ID,
},
menu::Menu,
ui_components::{
blended_colors,
icons::{Icon, ICON_DIMENSIONS},
menu_button::{
highlight_icon_button_with_context_menu_drive, icon_button_with_context_menu_drive,
MenuDirection,
},
},
};
use crate::{cloud_object::CloudObjectLocation, drive::items::WarpDriveItem};
use super::WarpDriveItemId;
pub(crate) fn tools_panel_menu_direction(app: &AppContext) -> MenuDirection {
let config = TabSettings::as_ref(app)
.header_toolbar_chip_selection
.clone();
if config
.left_items()
.contains(&HeaderToolbarItemKind::ToolsPanel)
{
MenuDirection::Right
} else {
MenuDirection::Left
}
}
#[derive(Default, Clone)]
pub struct ItemStates {
pub item_mouse_state: MouseStateHandle,
pub item_hover_state: MouseStateHandle,
pub menu_button_state: MouseStateHandle,
pub draggable_state: DraggableState,
pub item_sync_icon_hover_state: MouseStateHandle,
}
struct WarpDriveItemStyles {
// Height of each item
item_height: f32,
/// Default styles of the WarpDriveItem
default: UiComponentStyles,
/// On top of the default styles, active contains extra styling for when the item is being dragged
dragged: UiComponentStyles,
/// Similarly to active styles, hovered contains extra styling for a hovered item
hovered: UiComponentStyles,
}
impl WarpDriveItemStyles {
fn merge(self, style: UiComponentStyles) -> Self {
Self {
default: self.default.merge(style),
..self
}
}
fn default(appearance: &Appearance) -> WarpDriveItemStyles {
let theme = appearance.theme();
let item_height = ITEM_FONT_SIZE * 2.0 - ITEM_MARGIN_BOTTOM;
let background = theme.background();
WarpDriveItemStyles {
item_height,
default: UiComponentStyles::default()
.set_font_color(blended_colors::text_sub(theme, background))
.set_font_family_id(appearance.ui_builder().ui_font_family())
.set_font_size(ITEM_FONT_SIZE),
dragged: UiComponentStyles::default()
.set_font_family_id(appearance.ui_builder().ui_font_family())
.set_font_size(ITEM_FONT_SIZE)
.set_font_color(theme.foreground().into())
.set_background(
warp_core::ui::theme::color::internal_colors::fg_overlay_4(theme).into(),
)
.set_border_color(theme.accent().into()),
hovered: UiComponentStyles::default()
.set_font_family_id(appearance.ui_builder().ui_font_family())
.set_font_size(ITEM_FONT_SIZE)
.set_font_color(blended_colors::text_main(theme, background))
.set_background(
warp_core::ui::theme::color::internal_colors::fg_overlay_2(theme).into(),
),
}
}
}
/// A UI wrapper around a row in warp drive that holds important UI state for the row and implements
/// a unified look for all rows in warp drive, like padding and hover states.
///
/// The item-specific information like icon, name, click_action, and preview modal are abstracted as much as
/// possible into the WarpDriveType enum.
pub struct WarpDriveRow<'a> {
item: Box<dyn WarpDriveItem>,
space: Space,
item_states: ItemStates,
overflow_button: Box<dyn Element>,
/// how many levels into a folder hierachy the row is.
/// 0 means the object is in the root directory.
folder_depth: usize,
sync_icon: Option<Box<dyn Element>>,
can_move: bool,
styles: WarpDriveItemStyles,
menu_open: bool,
share_dialog_open: bool,
is_selected: bool,
is_focused: bool,
overflow_on_left: bool,
appearance: &'a Appearance,
}
impl<'a> WarpDriveRow<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
item: Box<dyn WarpDriveItem>,
item_states: ItemStates,
space: Space,
folder_depth: usize,
menu: ViewHandle<Menu<DriveIndexAction>>,
can_move: bool,
has_menu_items: bool,
menu_open: bool,
share_dialog_open: bool,
is_selected: bool,
is_focused: bool,
sync_queue_is_dequeueing: bool,
menu_direction: MenuDirection,
appearance: &'a Appearance,
) -> Option<Self> {
let warp_drive_item_id = item.warp_drive_id();
let overflow_button = match has_menu_items {
true => {
if is_focused || item_states.draggable_state.is_dragging() {
ConstrainedBox::new(
highlight_icon_button_with_context_menu_drive(
Icon::DotsVertical,
move |ctx, _, _| {
ctx.dispatch_typed_action(
DriveIndexAction::ToggleItemOverflowMenu {
space,
warp_drive_item_id,
},
);
},
item_states.menu_button_state.clone(),
&menu,
menu_open,
menu_direction,
appearance,
)
.finish(),
)
.with_width(20.)
.with_height(ICON_DIMENSIONS)
.finish()
} else {
ConstrainedBox::new(
icon_button_with_context_menu_drive(
Icon::DotsVertical,
move |ctx, _, _| {
ctx.dispatch_typed_action(
DriveIndexAction::ToggleItemOverflowMenu {
space,
warp_drive_item_id,
},
);
},
item_states.menu_button_state.clone(),
&menu,
menu_open,
menu_direction,
None, /* cursor */
appearance,
)
.finish(),
)
.with_width(20.)
.with_height(ICON_DIMENSIONS)
.finish()
}
}
false => Empty::new().finish(),
};
let sync_icon = item.sync_status_icon(
sync_queue_is_dequeueing,
item_states.item_sync_icon_hover_state.clone(),
appearance,
);
Some(Self {
item,
space,
item_states,
overflow_button,
folder_depth,
sync_icon,
can_move,
styles: WarpDriveItemStyles::default(appearance),
menu_open,
share_dialog_open,
is_selected,
is_focused,
overflow_on_left: matches!(menu_direction, MenuDirection::Left),
appearance,
})
}
#[allow(clippy::too_many_arguments)]
pub fn new_from_cloud_object(
object: &dyn CloudObject,
item_states: ItemStates,
space: Space,
folder_depth: usize,
menu: ViewHandle<Menu<DriveIndexAction>>,
can_move: bool,
has_menu_items: bool,
menu_open: bool,
share_dialog_open: bool,
is_selected: bool,
is_focused: bool,
sync_queue_is_dequeueing: bool,
menu_direction: MenuDirection,
appearance: &'a Appearance,
) -> Option<Self> {
let item = object.to_warp_drive_item(appearance)?;
Self::new(
item,
item_states,
space,
folder_depth,
menu,
can_move,
has_menu_items,
menu_open,
share_dialog_open,
is_selected,
is_focused,
sync_queue_is_dequeueing,
menu_direction,
appearance,
)
}
pub fn should_show_preview(&self) -> bool {
self.item_states
.item_hover_state
.lock()
.expect("Should be able to lock")
.is_hovered()
&& !self
.item_states
.item_sync_icon_hover_state
.lock()
.expect("Should be able to lock")
.is_hovered()
}
/// Wraps an object preview in some uniformly-styled modal. Also conditionally returns an empty
/// view if there's limited horizontal space.
pub fn render_preview(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Option<Box<dyn Element>> {
self.item.preview(appearance).map(|content_preview| {
let mut stacked_preview_panels: Vec<Box<dyn Element>> =
vec![Container::new(content_preview)
.with_uniform_padding(16.)
.finish()];
stacked_preview_panels.extend(self.render_shared_object_owner(appearance, app));
// Tracks whether the object history rectangle is the bottommost preview panel, which determines if we
// need to render with rounded corners or not.
let countdown = self.render_object_deletion_countdown(appearance, app);
// If there's an object history preview, add this panel to our vector.
if let Some(object_history) =
self.render_object_history(appearance, countdown.is_none(), app)
{
// Insert above the permadeletion stat if it exists.
stacked_preview_panels.push(object_history);
}
// If there's a deletion stat, add this panel to our vector.
if let Some(countdown) = countdown {
stacked_preview_panels.push(
Container::new(Empty::new().finish())
.with_border(
Border::bottom(1.).with_border_fill(appearance.theme().outline()),
)
.finish(),
);
stacked_preview_panels.push(countdown);
}
// The full hover preview is the column containing all these sub-panels.
let full_hover_preview = Flex::column().with_children(stacked_preview_panels);
SizeConstraintSwitch::new(
ConstrainedBox::new(
Container::new(full_hover_preview.finish())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_background(appearance.theme().surface_2())
.with_border(
Border::all(1.).with_border_fill(appearance.theme().surface_3()),
)
.with_drop_shadow(DropShadow::default())
.finish(),
)
.with_max_width(400.)
.finish(),
vec![(
SizeConstraintCondition::WidthLessThan(180.),
Empty::new().finish(),
)],
)
.finish()
})
}
/// Returns a Box<dyn Element> representing a displayable view of this cloud object's history, including
/// for example the last metadata on edits.
fn render_object_history(
&self,
appearance: &Appearance,
with_rounded_bottom: bool,
app: &AppContext,
) -> Option<Box<dyn Element>> {
if let Some(metadata) = self.item.metadata() {
let editing_history = metadata.semantic_editing_history(app);
let action_history = self.item.action_summary(app);
let full_object_history_text = match (editing_history, action_history) {
(Some(edits), Some(actions)) => format!("{edits} | {actions}"),
(Some(edits), None) => edits,
_ => return None,
};
let history_text = appearance
.ui_builder()
.wrappable_text(full_object_history_text, false)
.with_style(UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
.into(),
),
font_size: Some(12.),
font_weight: Some(Weight::Normal),
..Default::default()
})
.build()
.finish();
// Render the element to span its parent horizontally
let text_spanning_parent = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_child(Shrinkable::new(1., history_text).finish())
.finish();
let container = Container::new(text_spanning_parent)
.with_background(appearance.theme().surface_1())
.with_padding_top(8.)
.with_padding_bottom(8.)
.with_padding_left(16.)
.with_padding_right(16.);
// Conditionally add the rounded bottom
Some(if with_rounded_bottom {
container
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish()
} else {
container.finish()
})
} else {
None
}
}
/// Render owner information, only for shared objects.
fn render_shared_object_owner(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let WarpDriveItemId::Object(object_id) = self.item.warp_drive_id() else {
return None;
};
if CloudViewModel::as_ref(app).object_space(&object_id.uid(), app) != Some(Space::Shared) {
return None;
}
let owner = CloudModel::as_ref(app)
.get_by_uid(&object_id.uid())?
.permissions()
.owner;
let mut owner_label = "From ".to_string();
match owner {
Owner::User { user_uid } => {
match UserProfiles::as_ref(app).displayable_identifier_for_uid(user_uid) {
Some(user) => owner_label.push_str(&user),
None => owner_label.push_str("unknown user"),
}
}
Owner::Team { team_uid, .. } => owner_label.push_str(
UserWorkspaces::as_ref(app)
.team_from_uid(team_uid)
.map_or("unknown team", |team| &team.name),
),
}
let background = appearance.theme().surface_1();
let text_color = appearance.theme().sub_text_color(background);
let icon = Container::new(
ConstrainedBox::new(Icon::Users.to_warpui_icon(text_color).finish())
.with_height(15.)
.with_width(15.)
.finish(),
)
.with_margin_right(6.)
.finish();
let owner_text = appearance
.ui_builder()
.wrappable_text(owner_label, false)
.with_style(UiComponentStyles {
font_color: Some(text_color.into()),
..Default::default()
})
.build()
.finish();
Some(
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon)
.with_child(Shrinkable::new(1., owner_text).finish())
.finish(),
)
.with_background(background)
.with_vertical_padding(8.)
.with_horizontal_padding(16.)
.finish(),
)
}
fn render_object_deletion_countdown(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Option<Box<dyn Element>> {
if let Some(metadata) = self.item.metadata() {
if let Some(countdown) = metadata.semantic_permadeletion_countdown(app) {
let icon_and_text = Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(
ConstrainedBox::new(
Icon::Clock
.to_warpui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2()),
)
.finish(),
)
.with_height(15.)
.with_width(15.)
.finish(),
)
.with_margin_right(6.)
.finish(),
)
.with_child(
Shrinkable::new(
1.,
appearance
.ui_builder()
.wrappable_text(countdown, false)
.with_style(UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
.into(),
),
font_size: Some(12.),
font_weight: Some(Weight::Normal),
..Default::default()
})
.build()
.finish(),
)
.finish(),
)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
)
.finish();
Some(
Container::new(icon_and_text)
.with_background(appearance.theme().surface_1())
.with_padding_top(8.)
.with_padding_bottom(8.)
.with_padding_left(16.)
.with_padding_right(16.)
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish(),
)
} else {
None
}
} else {
None
}
}
fn render_chevron(&self, style: UiComponentStyles) -> Box<dyn Element> {
// Only render chevron for folders
if let Some(is_open) = self.item.is_folder_open() {
let chevron_icon = if is_open {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let icon_color = style.font_color.unwrap_or_else(|| {
blended_colors::text_sub(
self.appearance.theme(),
self.appearance.theme().background(),
)
});
Container::new(
ConstrainedBox::new(chevron_icon.to_warpui_icon(icon_color.into()).finish())
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_margin_right(4.)
.finish()
} else {
// Not a folder, render empty spacer to maintain alignment
Container::new(
ConstrainedBox::new(Empty::new().finish())
.with_width(16.)
.finish(),
)
.with_margin_right(4.)
.finish()
}
}
fn render_icon(&self, style: UiComponentStyles) -> Box<dyn Element> {
let icon_to_render = match self.item.warp_drive_id() {
// This sets the icon color of folders correctly in color contrast cases, e.g. being dragged or focused
WarpDriveItemId::Object(CloudObjectTypeAndId::Folder(_))
if style == self.styles.dragged =>
{
self.item
.icon(self.appearance, Some(style.font_color.unwrap().into()))
}
_ => self.item.icon(self.appearance, None),
};
if let Some(icon) = icon_to_render {
Container::new(
ConstrainedBox::new(icon)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_margin_right(8.)
.finish()
} else {
Empty::new().finish()
}
}
fn render_secondary_icon(&self, style: UiComponentStyles) -> Box<dyn Element> {
let icon_to_render = match self.item.warp_drive_id() {
WarpDriveItemId::Object(CloudObjectTypeAndId::Folder(_)) => self
.item
.secondary_icon(Some(style.font_color.unwrap().into())),
_ => self.item.secondary_icon(None),
};
if let Some(icon) = icon_to_render {
Container::new(
ConstrainedBox::new(icon)
.with_width(self.styles.default.font_size.unwrap_or_default())
.with_height(self.styles.default.font_size.unwrap_or_default())
.finish(),
)
.with_padding_left(2.)
.finish()
} else {
Empty::new().finish()
}
}
fn render_item_name(&self, style: UiComponentStyles) -> Box<dyn Element> {
Span::new(
self.item
.display_name()
.unwrap_or_else(|| "Untitled".to_string()),
style,
)
.build()
.finish()
}
pub fn render_item(&self, style: UiComponentStyles) -> Box<dyn Element> {
let chevron = self.render_chevron(style);
let icon = self.render_icon(style);
let name = self.render_item_name(style);
let secondary_icon = self.render_secondary_icon(style);
let item = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., name).finish())
.with_child(secondary_icon)
.finish();
let action = self.item.click_action();
let space = self.space;
let warp_drive_item_id = self.item.warp_drive_id();
match warp_drive_item_id {
WarpDriveItemId::Object(_)
| WarpDriveItemId::AIFactCollection
| WarpDriveItemId::MCPServerCollection => {
Hoverable::new(self.item_states.item_mouse_state.clone(), move |_| {
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(chevron)
.with_child(icon)
.with_child(Shrinkable::new(1., item).finish())
.finish(),
)
.with_margin_left(FOLDER_DEPTH_INDENT * self.folder_depth as f32)
.finish()
})
.on_click(move |ctx, _, _| {
if let Some(action) = action.clone() {
ctx.dispatch_typed_action(action);
}
})
.on_right_click(move |ctx, _, _| {
ctx.dispatch_typed_action(DriveIndexAction::ToggleItemOverflowMenu {
space,
warp_drive_item_id,
});
})
.finish()
}
_ => unreachable!(),
}
}
}
/// Generate a callback for calculating the Drag bounds within Warp Drive
fn drag_bounds_callback() -> impl Fn(&PositionCache, Vector2F) -> Option<RectF> {
move |position_cache, window: Vector2F| {
let drive_index = position_cache.get_position(WARP_DRIVE_POSITION_ID)?;
let top_left = drive_index.origin();
Some(RectF::from_points(top_left, window))
}
}
impl UiComponent for WarpDriveRow<'_> {
type ElementType = SavePosition;
fn build(self) -> Self::ElementType {
let is_dragging = self.item_states.draggable_state.is_dragging();
let overflow_on_left = self.overflow_on_left;
// This is ONLY for rendering the font color correctly, which is set at the render_item level
let style = if is_dragging || self.is_focused {
self.styles.dragged
} else {
self.styles.default
};
let inner_item = self.render_item(style);
// Hoverable here doesn't have any action, it's mostly used for setting background styling based on
// the mouse state
let hoverable_item = Hoverable::new(
self.item_states.item_hover_state.clone(),
move |mouse_state| {
// If dragging or object has been focused, then theme accent background.
// If hovering / menu is open / object has been selected, then thick overlay background.
// If an object is both selected and focused, show focused background
let container_background_fill = if is_dragging || self.is_focused {
self.styles.dragged.background
} else if mouse_state.is_hovered()
|| self.menu_open
|| self.is_selected
|| self.share_dialog_open
{
self.styles.hovered.background
} else {
None
};
let show_overflow = mouse_state.is_hovered() || self.menu_open;
let mut items_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
items_row.add_child(Shrinkable::new(1., inner_item).finish());
if let Some(sync_icon) = self.sync_icon {
items_row.add_child(Container::new(sync_icon).with_margin_right(4.).finish());
}
let row_element: Box<dyn Element> = if overflow_on_left {
let row_container = Container::new(items_row.finish())
.with_margin_left(INDEX_CONTENT_MARGIN_LEFT)
.with_padding_right(ITEM_PADDING_HORIZONTAL)
.with_padding_left(ITEM_PADDING_HORIZONTAL)
.with_padding_top(ITEM_PADDING_VERTICAL)
.with_padding_bottom(ITEM_PADDING_VERTICAL)
.finish();
if show_overflow {
let mut stack = Stack::new().with_child(row_container);
stack.add_positioned_child(
self.overflow_button,
OffsetPositioning::offset_from_parent(
pathfinder_geometry::vector::vec2f(0., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::MiddleLeft,
ChildAnchor::MiddleLeft,
),
);
stack.finish()
} else {
row_container
}
} else {
if show_overflow {
items_row.add_child(self.overflow_button);
}
Container::new(items_row.finish())
.with_margin_left(INDEX_CONTENT_MARGIN_LEFT)
.with_padding_right(ITEM_PADDING_HORIZONTAL)
.with_padding_left(ITEM_PADDING_HORIZONTAL)
.with_padding_top(ITEM_PADDING_VERTICAL)
.with_padding_bottom(ITEM_PADDING_VERTICAL)
.finish()
};
let result_container = Container::new(
ConstrainedBox::new(row_element)
.with_height(self.styles.item_height)
.finish(),
)
.with_margin_bottom(ITEM_MARGIN_BOTTOM)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
match container_background_fill {
Some(background_fill) => {
result_container.with_background(background_fill).finish()
}
None => result_container.finish(),
}
},
)
.with_cursor(Cursor::PointingHand)
.finish();
match self.item.warp_drive_id() {
WarpDriveItemId::Object(item) => {
let save_position_child = match self.can_move {
true => {
Draggable::new(self.item_states.draggable_state, hoverable_item)
.with_drag_bounds_callback(drag_bounds_callback())
.with_accepted_by_drop_target_fn(move |drop_data, app| {
let Some(location) =
drop_data.as_any().downcast_ref::<CloudObjectLocation>()
else {
return AcceptedByDropTarget::No;
};
let cloud_model = CloudModel::handle(app);
if cloud_model.as_ref(app).can_move_object_to_location(
&item.uid(),
*location,
app,
) {
AcceptedByDropTarget::Yes
} else {
AcceptedByDropTarget::No
}
})
.on_drop(move |ctx, _, _, data| {
if let Some(location) = data.and_then(|data| {
data.as_any().downcast_ref::<CloudObjectLocation>()
}) {
ctx.dispatch_typed_action(DriveIndexAction::DropIndexItem {
cloud_object_type_and_id: item,
drop_target_location: *location,
});
}
})
.on_drag(move |ctx, _, dragged_item, data| {
// First, check if we are over a drop target for styling
if let Some(location) = data.and_then(|data| {
data.as_any().downcast_ref::<CloudObjectLocation>()
}) {
ctx.dispatch_typed_action(
DriveIndexAction::UpdateCurrentDropTarget {
drop_target_location: *location,
},
)
} else {
ctx.dispatch_typed_action(DriveIndexAction::ClearDropTarget)
}
// On a drag event, check to see if the index needs to be scrolled up or down
// to reveal new content.
if let Some(drive_index_view_position) =
ctx.element_position_by_id(DRIVE_INDEX_VIEW_POSITION_ID)
{
// First, check to see if we should scroll upwards (revealing more content at the top).
// This computes the distance between the top of the *currently-dragging* item and the top
// of the drive index view. If distance < 10, we emit a scroll event back to the index view.
let pixels_from_top =
dragged_item.min_y() - drive_index_view_position.min_y();
if pixels_from_top < AUTOSCROLL_DETECTION_DISTANCE {
// The speed of the autoscroll is a function of (1) how far away from the relevant border the object is
// and (2) what the speed multiplier is.
// Note: Scrolling upwards is decreasing the scroll value, so we multiply by -1 in this case.
let scroll_speed = ((AUTOSCROLL_DETECTION_DISTANCE
- pixels_from_top)
/ AUTOSCROLL_DETECTION_DISTANCE)
* AUTOSCROLL_SPEED_MULTIPLIER;
ctx.dispatch_typed_action(DriveIndexAction::Autoscroll {
delta: -scroll_speed,
});
return;
}
// Otherwize, check to see if we should scroll downwards (revealing more content at the
// bottom).
// This computes the distance between the bottom of the *currently-dragging* item
// and the bottom of the drive index view. If distance < 10, emit a scroll event.
let pixels_from_bottom =
drive_index_view_position.max_y() - dragged_item.max_y();
if pixels_from_bottom < AUTOSCROLL_DETECTION_DISTANCE {
// See comment above about determining the speed of the scroll.
let scroll_speed = ((AUTOSCROLL_DETECTION_DISTANCE
- pixels_from_bottom)
/ AUTOSCROLL_DETECTION_DISTANCE)
* AUTOSCROLL_SPEED_MULTIPLIER;
ctx.dispatch_typed_action(DriveIndexAction::Autoscroll {
delta: scroll_speed,
})
}
}
})
.finish()
}
false => hoverable_item,
};
SavePosition::new(
save_position_child,
&self.item.warp_drive_id().drive_row_position_id(),
)
}
WarpDriveItemId::AIFactCollection | WarpDriveItemId::MCPServerCollection => {
SavePosition::new(
hoverable_item,
&self.item.warp_drive_id().drive_row_position_id(),
)
}
_ => unreachable!(),
}
}
fn with_style(self, style: UiComponentStyles) -> Self {
Self {
styles: self.styles.merge(style),
..self
}
}
}
+71
View File
@@ -0,0 +1,71 @@
use super::{WarpDriveItem, WarpDriveItemId};
use crate::{
ai::mcp::CloudMCPServer,
appearance::Appearance,
cloud_object::CloudObjectMetadata,
drive::{index::DriveIndexAction, CloudObjectTypeAndId, DriveObjectType},
themes::theme::Fill,
};
use warpui::{elements::MouseStateHandle, AppContext, Element};
#[derive(Clone)]
pub struct WarpDriveMCPServer {
id: CloudObjectTypeAndId,
mcp_server: CloudMCPServer,
}
impl WarpDriveMCPServer {
pub fn new(id: CloudObjectTypeAndId, mcp_server: CloudMCPServer) -> Self {
Self { id, mcp_server }
}
}
impl WarpDriveItem for WarpDriveMCPServer {
fn display_name(&self) -> Option<String> {
Some(self.mcp_server.model().string_model.name.clone())
}
fn metadata(&self) -> Option<&CloudObjectMetadata> {
Some(&self.mcp_server.metadata)
}
fn object_type(&self) -> Option<DriveObjectType> {
Some(DriveObjectType::MCPServer)
}
fn secondary_icon(&self, _color: Option<Fill>) -> Option<Box<dyn Element>> {
None
}
fn click_action(&self) -> Option<DriveIndexAction> {
Some(DriveIndexAction::OpenMCPServerCollection)
}
fn preview(&self, _appearance: &Appearance) -> Option<Box<dyn Element>> {
// TODO
None
}
fn warp_drive_id(&self) -> WarpDriveItemId {
WarpDriveItemId::Object(self.id)
}
fn sync_status_icon(
&self,
sync_queue_is_dequeueing: bool,
hover_state: MouseStateHandle,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
self.mcp_server
.metadata
.pending_changes_statuses
.render_icon(sync_queue_is_dequeueing, hover_state, appearance)
}
fn action_summary(&self, _app: &AppContext) -> Option<String> {
None
}
fn clone_box(&self) -> Box<dyn WarpDriveItem> {
Box::new(self.clone())
}
}
@@ -0,0 +1,73 @@
use warpui::{elements::MouseStateHandle, AppContext, Element};
use crate::{
appearance::Appearance,
cloud_object::CloudObjectMetadata,
drive::{index::DriveIndexAction, DriveObjectType},
server::ids::ClientId,
themes::theme::Fill,
};
use super::{WarpDriveItem, WarpDriveItemId};
#[derive(Clone)]
pub struct WarpDriveMCPServerCollection {
id: ClientId,
}
impl WarpDriveMCPServerCollection {
pub fn new(id: ClientId) -> Self {
Self { id }
}
pub fn id(&self) -> ClientId {
self.id
}
}
impl WarpDriveItem for WarpDriveMCPServerCollection {
fn display_name(&self) -> Option<String> {
Some("MCP Servers".to_string())
}
fn metadata(&self) -> Option<&CloudObjectMetadata> {
None
}
fn object_type(&self) -> Option<DriveObjectType> {
Some(DriveObjectType::MCPServerCollection)
}
fn secondary_icon(&self, _color: Option<Fill>) -> Option<Box<dyn Element>> {
None
}
fn click_action(&self) -> Option<DriveIndexAction> {
Some(DriveIndexAction::OpenMCPServerCollection)
}
fn preview(&self, _appearance: &Appearance) -> Option<Box<dyn Element>> {
None
}
fn warp_drive_id(&self) -> WarpDriveItemId {
WarpDriveItemId::MCPServerCollection
}
fn sync_status_icon(
&self,
_sync_queue_is_dequeueing: bool,
_hover_state: MouseStateHandle,
_appearance: &Appearance,
) -> Option<Box<dyn Element>> {
None
}
fn action_summary(&self, _app: &AppContext) -> Option<String> {
None
}
fn clone_box(&self) -> Box<dyn WarpDriveItem> {
Box::new(self.clone())
}
}
+89
View File
@@ -0,0 +1,89 @@
use warpui::{elements::MouseStateHandle, AppContext, Element};
use crate::{
appearance::Appearance,
cloud_object::{CloudObjectMetadata, Space},
themes::theme::Fill,
ui_components::icons::Icon,
};
use super::{
cloud_object_styling::warp_drive_icon_color,
index::{warp_drive_section_header_position_id, DriveIndexAction, DriveIndexSection},
CloudObjectTypeAndId, DriveObjectType,
};
pub mod ai_fact;
pub mod ai_fact_collection;
pub mod env_var_collection;
pub mod folder;
pub mod item;
pub mod mcp_server;
pub mod mcp_server_collection;
pub mod notebook;
pub mod space;
pub mod workflow;
pub trait WarpDriveItem {
/// The display name of the item. If the item is unnamed, this may return `None` - implementations
/// should prefer this over `Some("")`, as it lets the index view use alternate styling.
fn display_name(&self) -> Option<String>;
fn metadata(&self) -> Option<&CloudObjectMetadata>;
fn object_type(&self) -> Option<DriveObjectType>;
fn secondary_icon(&self, color: Option<Fill>) -> Option<Box<dyn Element>>; // The optional icon to the right of the name
fn click_action(&self) -> Option<DriveIndexAction>;
fn preview(&self, appearance: &Appearance) -> Option<Box<dyn Element>>;
fn warp_drive_id(&self) -> WarpDriveItemId;
fn sync_status_icon(
&self,
sync_queue_is_dequeueing: bool,
hover_state: MouseStateHandle,
appearance: &Appearance,
) -> Option<Box<dyn Element>>;
fn icon(&self, appearance: &Appearance, color: Option<Fill>) -> Option<Box<dyn Element>> {
let object_type = self.object_type()?;
let icon_fill = color.unwrap_or(warp_drive_icon_color(appearance, object_type).into());
Some(Icon::from(object_type).to_warpui_icon(icon_fill).finish())
}
/// If implemented, returns a string that summarizes the primary action history. For example, "Run 2 times in the last week"
fn action_summary(&self, app: &AppContext) -> Option<String>;
/// Returns Some(true) if this is an open folder, Some(false) if closed folder, None if not a folder
fn is_folder_open(&self) -> Option<bool> {
None
}
fn clone_box(&self) -> Box<dyn WarpDriveItem>;
}
impl WarpDriveItemId {
pub fn drive_row_position_id(&self) -> String {
match self {
Self::AIFactCollection => "AI_fact_collection".to_string(),
Self::MCPServerCollection => "MCP_server_collection".to_string(),
Self::Object(object_id) => object_id.drive_row_position_id(),
Self::Space(space) => {
warp_drive_section_header_position_id(&DriveIndexSection::Space(*space))
}
Self::Trash => "Trash".to_string(),
}
}
}
/// This uniquely identifies an item in Warp Drive index
/// Includes spaces (which CloudObjectTypeAndId does not entail)
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
pub enum WarpDriveItemId {
AIFactCollection,
MCPServerCollection,
Object(CloudObjectTypeAndId),
Space(Space),
Trash,
}
impl Clone for Box<dyn WarpDriveItem> {
fn clone(&self) -> Self {
self.clone_box()
}
}
+113
View File
@@ -0,0 +1,113 @@
use warpui::{
elements::{Flex, MouseStateHandle, ParentElement},
fonts::Weight,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element,
};
use crate::{
appearance::Appearance,
cloud_object::CloudObjectMetadata,
drive::{index::DriveIndexAction, CloudObjectTypeAndId, DriveObjectType},
notebooks::CloudNotebook,
themes::theme::Fill,
};
use super::{WarpDriveItem, WarpDriveItemId};
#[derive(Clone)]
pub struct WarpDriveNotebook {
id: CloudObjectTypeAndId,
notebook: CloudNotebook,
is_ai_document: bool,
}
impl WarpDriveNotebook {
pub fn new(id: CloudObjectTypeAndId, notebook: CloudNotebook, is_ai_document: bool) -> Self {
Self {
id,
notebook,
is_ai_document,
}
}
}
impl WarpDriveItem for WarpDriveNotebook {
fn display_name(&self) -> Option<String> {
if self.notebook.model().title.is_empty() {
None
} else {
Some(self.notebook.model().title.clone())
}
}
fn metadata(&self) -> Option<&CloudObjectMetadata> {
Some(&self.notebook.metadata)
}
fn object_type(&self) -> Option<DriveObjectType> {
Some(DriveObjectType::Notebook {
is_ai_document: self.is_ai_document,
})
}
fn secondary_icon(&self, _color: Option<Fill>) -> Option<Box<dyn Element>> {
None
}
fn click_action(&self) -> Option<DriveIndexAction> {
Some(DriveIndexAction::OpenObject(self.id))
}
fn preview(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
let title_text = self.notebook.model().title.clone();
let title_to_render = if title_text.is_empty() {
"Untitled".to_string()
} else {
title_text
};
let title = appearance
.ui_builder()
.wrappable_text(title_to_render, true)
.with_style(UiComponentStyles {
font_color: Some(
appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
),
font_size: Some(14.),
font_weight: Some(Weight::Bold),
..Default::default()
})
.build()
.finish();
Some(Flex::column().with_child(title).finish())
}
fn warp_drive_id(&self) -> WarpDriveItemId {
WarpDriveItemId::Object(self.id)
}
fn sync_status_icon(
&self,
sync_queue_is_dequeueing: bool,
hover_state: MouseStateHandle,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
self.notebook.metadata.pending_changes_statuses.render_icon(
sync_queue_is_dequeueing,
hover_state,
appearance,
)
}
fn action_summary(&self, _app: &AppContext) -> Option<String> {
None
}
fn clone_box(&self) -> Box<dyn WarpDriveItem> {
Box::new(self.clone())
}
}
+69
View File
@@ -0,0 +1,69 @@
use warpui::{elements::MouseStateHandle, Element};
use crate::{
appearance::Appearance,
cloud_object::{CloudObjectMetadata, Space},
drive::{index::DriveIndexAction, DriveObjectType},
themes::theme::Fill,
};
use super::{WarpDriveItem, WarpDriveItemId};
#[derive(Clone)]
pub struct WarpDriveSpace {
space: Space,
}
impl WarpDriveSpace {
#[allow(dead_code)]
pub fn new(space: Space) -> Self {
Self { space }
}
}
impl WarpDriveItem for WarpDriveSpace {
fn display_name(&self) -> Option<String> {
None
}
fn metadata(&self) -> Option<&CloudObjectMetadata> {
None
}
fn object_type(&self) -> Option<DriveObjectType> {
None
}
fn secondary_icon(&self, _color: Option<Fill>) -> Option<Box<dyn Element>> {
None
}
fn click_action(&self) -> Option<DriveIndexAction> {
None
}
fn preview(&self, _appearance: &Appearance) -> Option<Box<dyn Element>> {
None
}
fn warp_drive_id(&self) -> WarpDriveItemId {
WarpDriveItemId::Space(self.space)
}
fn sync_status_icon(
&self,
_sync_queue_is_dequeueing: bool,
_hover_state: MouseStateHandle,
_appearance: &Appearance,
) -> Option<Box<dyn Element>> {
None
}
fn clone_box(&self) -> Box<dyn WarpDriveItem> {
Box::new(self.clone())
}
fn action_summary(&self, _app: &warpui::AppContext) -> Option<String> {
None
}
}
+183
View File
@@ -0,0 +1,183 @@
use warp_core::context_flag::ContextFlag;
use warpui::{
elements::{Container, Flex, MouseStateHandle, ParentElement},
fonts::Weight,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, SingletonEntity,
};
use crate::{
appearance::Appearance,
cloud_object::{
model::actions::{ObjectActionType, ObjectActions},
CloudObjectMetadata,
},
drive::{index::DriveIndexAction, CloudObjectTypeAndId, DriveObjectType},
themes::theme::Fill,
workflows::{CloudWorkflow, WorkflowViewMode},
};
use super::{WarpDriveItem, WarpDriveItemId};
#[derive(Clone)]
pub struct WarpDriveWorkflow {
id: CloudObjectTypeAndId,
workflow: CloudWorkflow,
}
impl WarpDriveWorkflow {
pub fn new(id: CloudObjectTypeAndId, workflow: CloudWorkflow) -> Self {
Self { id, workflow }
}
}
impl WarpDriveItem for WarpDriveWorkflow {
fn display_name(&self) -> Option<String> {
if self.workflow.model().data.name().is_empty() {
None
} else {
Some(self.workflow.model().data.name().to_owned())
}
}
fn metadata(&self) -> Option<&CloudObjectMetadata> {
Some(&self.workflow.metadata)
}
fn object_type(&self) -> Option<DriveObjectType> {
if self.workflow.model().data.is_agent_mode_workflow() {
Some(DriveObjectType::AgentModeWorkflow)
} else {
Some(DriveObjectType::Workflow)
}
}
fn secondary_icon(&self, _color: Option<Fill>) -> Option<Box<dyn Element>> {
None
}
fn click_action(&self) -> Option<DriveIndexAction> {
if !ContextFlag::RunWorkflow.is_enabled() {
// If we are in a context where we can't run workflows, open it in view mode
// by default
Some(DriveIndexAction::OpenWorkflowInPane {
cloud_object_type_and_id: self.id,
open_mode: WorkflowViewMode::View,
})
} else {
Some(DriveIndexAction::RunObject(self.id))
}
}
fn preview(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
let mut modal =
Flex::column().with_cross_axis_alignment(warpui::elements::CrossAxisAlignment::Stretch);
let mut text = Flex::column()
.with_child(Container::new(self.render_workflow_name(appearance)).finish());
if let Some(description) = self.workflow.model().data.description() {
let description_text = appearance
.ui_builder()
.paragraph(description.clone())
.with_style(UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
.into(),
),
font_size: Some(12.),
..Default::default()
});
text.add_child(
Container::new(description_text.build().finish())
.with_margin_top(4.)
.finish(),
)
}
let content = self.render_workflow_content(appearance);
modal.add_children([
Container::new(text.finish())
.with_margin_bottom(12.)
.finish(),
content,
]);
Some(modal.finish())
}
fn warp_drive_id(&self) -> WarpDriveItemId {
WarpDriveItemId::Object(self.id)
}
fn sync_status_icon(
&self,
sync_queue_is_dequeueing: bool,
hover_state: MouseStateHandle,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
self.workflow.metadata.pending_changes_statuses.render_icon(
sync_queue_is_dequeueing,
hover_state,
appearance,
)
}
fn action_summary(&self, app: &AppContext) -> Option<String> {
ObjectActions::as_ref(app)
.get_action_history_summary_for_action_type(&self.id.uid(), ObjectActionType::Execute)
}
fn clone_box(&self) -> Box<dyn WarpDriveItem> {
Box::new(self.clone())
}
}
impl WarpDriveWorkflow {
fn render_workflow_name(&self, appearance: &Appearance) -> Box<dyn Element> {
appearance
.ui_builder()
.wrappable_text(self.workflow.model().data.name().to_owned(), true)
.with_style(UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
),
font_size: Some(14.),
font_weight: Some(Weight::Bold),
..Default::default()
})
.build()
.finish()
}
fn render_workflow_content(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
Container::new(
appearance
.ui_builder()
.paragraph(self.workflow.model().data.content().to_owned())
.with_style(UiComponentStyles {
font_family_id: Some(if self.workflow.model().data.is_agent_mode_workflow() {
appearance.ui_font_family()
} else {
appearance.monospace_font_family()
}),
font_color: Some(theme.main_text_color(theme.surface_2()).into()),
font_size: Some(12.),
..Default::default()
})
.build()
.finish(),
)
.finish()
}
}
+360
View File
@@ -0,0 +1,360 @@
pub mod cloud_action_confirmation_dialog;
mod cloud_object_naming_dialog;
pub mod cloud_object_styling;
pub mod drive_helpers;
pub mod empty_trash_confirmation_dialog;
pub mod export;
pub mod folders;
pub mod import;
pub(crate) mod index;
pub mod items;
pub mod panel;
pub mod settings;
pub mod sharing;
pub mod workflows;
use std::{cmp::Ordering, fmt};
pub use index::DriveIndexVariant;
pub use panel::{DrivePanel, DrivePanelEvent};
use serde::{Deserialize, Serialize};
use warp_core::user_preferences::GetUserPreferences as _;
use warpui::AppContext;
use crate::{
cloud_object::{
model::view::{CloudViewModel, UpdateTimestamp},
CloudObject, GenericStringObjectFormat, ObjectIdType, ObjectType,
},
server::ids::{HashedSqliteId, ObjectUid, ServerId, SyncId},
ui_components::icons::Icon,
workflows::CloudWorkflow,
};
type SortByComparator<'a> = dyn FnMut(&&dyn CloudObject, &&dyn CloudObject) -> Ordering + 'a;
#[derive(Copy, Clone, Debug)]
pub enum DriveObjectType {
Workflow,
AgentModeWorkflow,
AIFact,
AIFactCollection,
Notebook {
/// Whether the notebook was created as an AI Document (plan)
is_ai_document: bool,
},
Folder,
EnvVarCollection,
MCPServer,
MCPServerCollection,
}
impl From<DriveObjectType> for Icon {
fn from(cloud_object_type: DriveObjectType) -> Icon {
match cloud_object_type {
DriveObjectType::Workflow => Icon::Workflow,
DriveObjectType::AgentModeWorkflow => Icon::Prompt,
DriveObjectType::AIFact => Icon::BookOpen,
DriveObjectType::AIFactCollection => Icon::BookOpen,
DriveObjectType::Notebook { is_ai_document } => {
if is_ai_document {
Icon::Compass
} else {
Icon::Notebook
}
}
DriveObjectType::Folder => Icon::Folder,
DriveObjectType::EnvVarCollection => Icon::EnvVarCollection,
DriveObjectType::MCPServer => Icon::Dataflow,
DriveObjectType::MCPServerCollection => Icon::Dataflow,
}
}
}
impl fmt::Display for DriveObjectType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DriveObjectType::Notebook { .. } => write!(f, "notebook"),
DriveObjectType::Workflow => write!(f, "workflow"),
DriveObjectType::Folder => write!(f, "folder"),
DriveObjectType::EnvVarCollection => write!(f, "env var collection"),
DriveObjectType::AgentModeWorkflow => write!(f, "prompt"),
DriveObjectType::AIFact => write!(f, "ai fact"),
DriveObjectType::AIFactCollection => write!(f, "ai fact collection"),
DriveObjectType::MCPServer => write!(f, "mcp server"),
DriveObjectType::MCPServerCollection => write!(f, "mcp server collection"),
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Default)]
pub struct OpenWarpDriveObjectSettings {
/// The folder that should be focused in the Warp Drive when the object is opened.
pub focused_folder_id: Option<ServerId>,
/// The email of the user to invite to the object, if the object is being opened via the request access flow.
pub invitee_email: Option<String>,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct OpenWarpDriveObjectArgs {
pub object_type: ObjectType,
pub server_id: ServerId,
pub settings: OpenWarpDriveObjectSettings,
}
/// Enum to use to pass down type and id between actions to avoid multiplying actions whenever we
/// need to pass the object id etc.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum CloudObjectTypeAndId {
Notebook(SyncId),
Workflow(SyncId),
Folder(SyncId),
GenericStringObject {
object_type: GenericStringObjectFormat,
id: SyncId,
},
}
impl CloudObjectTypeAndId {
pub fn from_id_and_type(id: SyncId, object_type: ObjectType) -> Self {
match object_type {
ObjectType::Notebook => Self::Notebook(id),
ObjectType::Workflow => Self::Workflow(id),
ObjectType::Folder => Self::Folder(id),
ObjectType::GenericStringObject(format) => Self::GenericStringObject {
object_type: format,
id,
},
}
}
pub fn uid(self) -> ObjectUid {
match self {
Self::Notebook(id) => id.uid(),
Self::Workflow(id) => id.uid(),
Self::Folder(id) => id.uid(),
Self::GenericStringObject { id, .. } => id.uid(),
}
}
pub fn sync_id(self) -> SyncId {
match self {
Self::Notebook(id)
| Self::Workflow(id)
| Self::Folder(id)
| Self::GenericStringObject { id, .. } => id,
}
}
pub fn sqlite_uid_hash(self) -> HashedSqliteId {
match self {
CloudObjectTypeAndId::Notebook(id) => id.sqlite_uid_hash(ObjectIdType::Notebook),
CloudObjectTypeAndId::Workflow(id) => id.sqlite_uid_hash(ObjectIdType::Workflow),
CloudObjectTypeAndId::Folder(id) => id.sqlite_uid_hash(ObjectIdType::Folder),
CloudObjectTypeAndId::GenericStringObject { object_type: _, id } => {
id.sqlite_uid_hash(ObjectIdType::GenericStringObject)
}
}
}
pub fn object_id_type(&self) -> ObjectIdType {
match self {
CloudObjectTypeAndId::Notebook(_) => ObjectIdType::Notebook,
CloudObjectTypeAndId::Workflow(_) => ObjectIdType::Workflow,
CloudObjectTypeAndId::GenericStringObject { .. } => ObjectIdType::GenericStringObject,
CloudObjectTypeAndId::Folder(_) => ObjectIdType::Folder,
}
}
pub fn object_type(&self) -> ObjectType {
match self {
CloudObjectTypeAndId::Notebook(_) => ObjectType::Notebook,
CloudObjectTypeAndId::Workflow(_) => ObjectType::Workflow,
CloudObjectTypeAndId::Folder(_) => ObjectType::Folder,
CloudObjectTypeAndId::GenericStringObject { object_type, .. } => {
ObjectType::GenericStringObject(*object_type)
}
}
}
pub fn as_folder_id(self) -> Option<SyncId> {
match self {
CloudObjectTypeAndId::Notebook(_) => None,
CloudObjectTypeAndId::Workflow(_) => None,
CloudObjectTypeAndId::GenericStringObject { .. } => None,
CloudObjectTypeAndId::Folder(f) => Some(f),
}
}
pub fn as_notebook_id(self) -> Option<SyncId> {
match self {
CloudObjectTypeAndId::Notebook(id) => Some(id),
_ => None,
}
}
pub fn as_generic_string_object_id(self) -> Option<SyncId> {
match self {
CloudObjectTypeAndId::GenericStringObject { object_type: _, id } => Some(id),
_ => None,
}
}
pub fn has_server_id(self) -> bool {
matches!(
self,
CloudObjectTypeAndId::Notebook(SyncId::ServerId(_))
| CloudObjectTypeAndId::Workflow(SyncId::ServerId(_))
| CloudObjectTypeAndId::Folder(SyncId::ServerId(_))
| CloudObjectTypeAndId::GenericStringObject {
id: SyncId::ServerId(_),
..
}
)
}
pub fn server_id(self) -> Option<ServerId> {
match self {
CloudObjectTypeAndId::Notebook(SyncId::ServerId(notebook_id)) => Some(notebook_id),
CloudObjectTypeAndId::Workflow(SyncId::ServerId(workflow_id)) => Some(workflow_id),
CloudObjectTypeAndId::Folder(SyncId::ServerId(folder_id)) => Some(folder_id),
CloudObjectTypeAndId::GenericStringObject {
id: SyncId::ServerId(json_object_id),
..
} => Some(json_object_id),
_ => None,
}
}
pub fn drive_row_position_id(self) -> String {
format!("WarpDriveRow_{}", self.uid())
}
pub fn from_generic_string_object(object_type: GenericStringObjectFormat, id: SyncId) -> Self {
Self::GenericStringObject { object_type, id }
}
}
pub fn should_auto_open_welcome_folder(app: &mut AppContext) -> bool {
app.private_user_preferences()
.read_value(settings::HAS_AUTO_OPENED_WELCOME_FOLDER)
.unwrap_or_default()
.and_then(|s| serde_json::from_str(&s).ok())
.map(|has_opened: bool| !has_opened)
.unwrap_or(true)
}
pub fn write_has_auto_opened_welcome_folder_to_user_defaults(app: &mut AppContext) {
let _ = app
.private_user_preferences()
.write_value(settings::HAS_AUTO_OPENED_WELCOME_FOLDER, true.to_string());
}
/// Enum used for sorting elements in the Warp Drive Index (and potentially other places).
/// In the future it can be used to add other options (like, by name or by author), and exposed to
/// users in the index.
#[derive(
Default,
PartialEq,
Eq,
Hash,
Clone,
Copy,
Debug,
Serialize,
Deserialize,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(
description = "Sort order for Warp Drive items.",
rename_all = "snake_case"
)]
pub enum DriveSortOrder {
/// Sort by newest revision first in main index, most recently trashed in trash index
#[default]
ByTimestamp,
/// A => Z
AlphabeticalDescending,
/// Z => A
AlphabeticalAscending,
/// Sort by object type, with folders first
ByObjectType,
}
impl DriveSortOrder {
/// Returns the comparator that can be used for sorting items returned by
/// CloudModel::cloud_objects_in_space, for example (so more specifically, on the iterator of
/// type Iterator<Item = &'_ dyn CloudObject>)
pub fn sort_by<'a>(
&self,
cloud_model: &'a CloudViewModel,
update_timestamp: UpdateTimestamp,
app: &'a AppContext,
) -> Box<SortByComparator<'a>> {
match self {
// Sorts newly-created objects to be at the top of the list
Self::ByTimestamp => Box::new(
move |a: &&dyn CloudObject, b: &&dyn CloudObject| -> Ordering {
cloud_model
.object_sorting_timestamp(*a, update_timestamp, app)
.cmp(&cloud_model.object_sorting_timestamp(*b, update_timestamp, app))
.reverse()
},
),
Self::AlphabeticalDescending => Box::new(
move |a: &&dyn CloudObject, b: &&dyn CloudObject| -> Ordering {
a.display_name()
.to_lowercase()
.cmp(&b.display_name().to_lowercase())
},
),
Self::AlphabeticalAscending => Box::new(
move |a: &&dyn CloudObject, b: &&dyn CloudObject| -> Ordering {
b.display_name()
.to_lowercase()
.cmp(&a.display_name().to_lowercase())
},
),
Self::ByObjectType => Box::new(
move |a: &&dyn CloudObject, b: &&dyn CloudObject| -> Ordering {
let order = |obj: &&dyn CloudObject| match obj.object_type() {
ObjectType::Folder => 0,
ObjectType::GenericStringObject(_) => 1,
ObjectType::Notebook => 2,
ObjectType::Workflow => {
let Some(workflow) = obj.as_any().downcast_ref::<CloudWorkflow>()
else {
return 3;
};
if workflow.model().data.is_agent_mode_workflow() {
4
} else {
3
}
}
};
// First compare by object type ordering, then by display name alphabetically if equal
order(a).cmp(&order(b)).then_with(|| {
a.display_name()
.to_lowercase()
.cmp(&b.display_name().to_lowercase())
})
},
),
}
}
/// Returns the text that is used to display the sorting option in the KnowledgeIndex's sorting menu
pub fn menu_text(&self, index_variant: DriveIndexVariant) -> &str {
match (self, index_variant) {
(DriveSortOrder::ByTimestamp, DriveIndexVariant::MainIndex) => "Last updated",
(DriveSortOrder::ByTimestamp, DriveIndexVariant::Trash) => "Last trashed",
(DriveSortOrder::AlphabeticalDescending, _) => "A to Z",
(DriveSortOrder::AlphabeticalAscending, _) => "Z to A",
(DriveSortOrder::ByObjectType, _) => "Type",
}
}
}
+739
View File
@@ -0,0 +1,739 @@
use futures::Future;
use warpui::{
elements::{Align, Flex, Hoverable, MouseStateHandle, ParentElement, SavePosition, Shrinkable},
presenter::ChildView,
windowing::{StateEvent, WindowManager},
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::{
ai::{document::ai_document_model::AIDocumentId, facts::CloudAIFactModel},
cloud_object::{
model::{persistence::CloudModel, view::CloudViewModel},
CloudObjectEventEntrypoint, GenericStringObjectFormat, JsonObjectType, Owner, Space,
},
env_vars::{manager::EnvVarCollectionSource, CloudEnvVarCollection},
notebooks::{manager::NotebookSource, CloudNotebook},
server::{
cloud_objects::update_manager::{InitiatedBy, UpdateManager},
ids::{ClientId, ServerId, SyncId},
telemetry::SharingDialogSource,
},
workflows::{manager::WorkflowOpenSource, CloudWorkflow, WorkflowViewMode},
workspaces::user_workspaces::UserWorkspaces,
};
use super::{
drive_helpers::{
has_feature_gated_anonymous_user_reached_env_var_limit,
has_feature_gated_anonymous_user_reached_notebook_limit,
has_feature_gated_anonymous_user_reached_workflow_limit,
},
index::{DriveIndex, DriveIndexAction, DriveIndexEvent},
items::WarpDriveItemId,
CloudObjectTypeAndId, DriveObjectType,
};
pub const MIN_SIDEBAR_WIDTH: f32 = 250.;
pub const MAX_SIDEBAR_WIDTH_RATIO: f32 = 0.75;
pub const WARP_DRIVE_POSITION_ID: &str = "warp_drive";
/// The sidebar that houses Warp Drive.
/// `DrivePanel` is different from `DriveIndex` in that it is responsible for
/// how Warp Drive interacts with the workspace and the rest of the app, whereas
/// `DriveIndex` is the main warp drive view and responsible for the internals of Warp Drive.
pub struct DrivePanel {
index_view: ViewHandle<DriveIndex>,
mouse_state_handles: MouseStateHandles,
}
#[derive(Clone, Default)]
struct MouseStateHandles {
focus_panel_mouse_state: MouseStateHandle,
}
#[derive(Clone, Debug)]
pub enum DrivePanelAction {
/// Open the search dialog.
OpenSearch,
/// Focus WD panel (via single click)
FocusDriveIndex,
}
#[derive(Clone, Debug)]
pub enum DrivePanelEvent {
RunWorkflow(Box<CloudWorkflow>),
InvokeEnvironmentVariables {
env_var_collection: Box<CloudEnvVarCollection>,
in_subshell: bool,
},
OpenSearch,
OpenSharedObjectsCreationDeniedModal(DriveObjectType, ServerId),
OpenTeamSettingsPage,
OpenAIFactCollection,
OpenMCPServerCollection,
OpenImportModal {
owner: Owner,
initial_folder_id: Option<SyncId>,
},
OpenWorkflowModalWithNew {
space: Space,
initial_folder_id: Option<SyncId>,
},
OpenWorkflowModalWithCloudWorkflow(SyncId),
OpenNotebook(NotebookSource),
OpenEnvVarCollection(EnvVarCollectionSource),
OpenWorkflowInPane(WorkflowOpenSource, WorkflowViewMode),
FocusWarpDrive,
AttachPlanAsContext(AIDocumentId),
}
impl DrivePanel {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let index_view = ctx.add_typed_action_view(move |ctx| {
let mut index = DriveIndex::new(ctx);
index.initialize_section_states(ctx);
index
});
ctx.subscribe_to_view(&index_view, |me, _, event, ctx| {
me.handle_index_view_event(event, ctx);
});
// Subscribe to window state changes for focus dimming updates
let state_handle = WindowManager::handle(ctx);
ctx.subscribe_to_model(&state_handle, |_me, _, event, ctx| {
match &event {
StateEvent::ValueChanged { current, previous } => {
// Re-render if this window's focus state has changed
if WindowManager::did_window_change_focus(ctx.window_id(), current, previous) {
ctx.notify();
}
}
}
});
Self {
index_view,
mouse_state_handles: Default::default(),
}
}
/// Helper to get the [`Owner`] for a new object created from the index.
fn new_object_owner(
space: Space,
initial_folder_id: Option<&SyncId>,
app: &AppContext,
) -> Option<Owner> {
match initial_folder_id {
Some(folder_id) => CloudModel::as_ref(app)
.get_folder(folder_id)
.map(|folder| folder.permissions.owner),
None => UserWorkspaces::as_ref(app).space_to_owner(space, app),
}
}
/// Event handler for actions that occur within the index view
fn handle_index_view_event(&mut self, event: &DriveIndexEvent, ctx: &mut ViewContext<Self>) {
match event {
DriveIndexEvent::CreateNotebook {
space,
title,
initial_folder_id,
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
Some(owner) => {
ctx.emit(DrivePanelEvent::OpenNotebook(NotebookSource::New {
title: title.clone(),
owner,
initial_folder_id: *initial_folder_id,
}));
}
None => {
log::error!("Cannot identify a notebook owner from {space:?}");
}
},
DriveIndexEvent::OpenImportModal {
space,
initial_folder_id,
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
Some(owner) => ctx.emit(DrivePanelEvent::OpenImportModal {
owner,
initial_folder_id: *initial_folder_id,
}),
None => {
log::error!("Cannot identify an import target from {space:?}");
}
},
DriveIndexEvent::CreateFolder {
space,
title,
initial_folder_id,
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
Some(owner) => {
let client_id = ClientId::default();
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_folder(
title.clone(),
owner,
client_id,
*initial_folder_id,
true,
InitiatedBy::User,
ctx,
);
});
}
None => {
log::error!("Cannot identify a folder owner from {space:?}");
}
},
DriveIndexEvent::CreateEnvVarCollection {
space,
title,
initial_folder_id,
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
Some(owner) => ctx.emit(DrivePanelEvent::OpenEnvVarCollection(
EnvVarCollectionSource::New {
title: title.clone(),
owner,
initial_folder_id: *initial_folder_id,
},
)),
None => {
log::error!("Cannot identify an env var owner from {space:?}");
}
},
DriveIndexEvent::CreateWorkflow {
space,
title,
initial_folder_id,
is_for_agent_mode,
content,
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
Some(owner) => ctx.emit(DrivePanelEvent::OpenWorkflowInPane(
WorkflowOpenSource::New {
title: title.clone(),
content: content.clone(),
owner,
initial_folder_id: *initial_folder_id,
is_for_agent_mode: *is_for_agent_mode,
},
WorkflowViewMode::Create,
)),
None => {
log::error!("Cannot identify a workflow owner from {space:?}");
}
},
DriveIndexEvent::OpenAIFactCollection => {
self.open_ai_fact_collection_pane(ctx);
}
DriveIndexEvent::OpenMCPServerCollection => {
self.open_mcp_server_collection_pane(ctx);
}
DriveIndexEvent::OpenWorkflowInPane {
cloud_object_type_and_id,
open_mode,
} => {
let cloud_model = CloudModel::as_ref(ctx);
let object = cloud_model.get_by_uid(&cloud_object_type_and_id.uid());
let workflow: Option<&CloudWorkflow> = object.and_then(|object| object.into());
if let Some(workflow) = workflow {
self.open_existing_workflow_in_pane(workflow.id, *open_mode, ctx);
}
}
DriveIndexEvent::OpenObject(cloud_object_type_and_id) => {
let cloud_model = CloudModel::as_ref(ctx);
let object = cloud_model.get_by_uid(&cloud_object_type_and_id.uid());
let notebook_id = object.and_then(|object| {
let notebook: Option<&CloudNotebook> = object.into();
notebook.map(|notebook| notebook.id)
});
let workflow: Option<&CloudWorkflow> = object.and_then(|object| object.into());
let env_var_collection_id = object.and_then(|object| {
let env_var_collection: Option<&CloudEnvVarCollection> = object.into();
env_var_collection.map(|env_var_collection| env_var_collection.id)
});
if let Some(notebook_id) = notebook_id {
self.open_existing_notebook(notebook_id, ctx);
} else if let Some(workflow) = workflow {
self.open_workflow_modal_with_existing(workflow.id, ctx);
} else if let Some(env_var_collection_id) = env_var_collection_id {
self.open_existing_env_var_collection(env_var_collection_id, ctx);
}
}
DriveIndexEvent::DuplicateObject(cloud_object_type_and_id) => {
self.duplicate_object(cloud_object_type_and_id, ctx);
}
#[cfg(feature = "local_fs")]
DriveIndexEvent::ExportObject(cloud_object_type_and_id) => {
let window_id = ctx.window_id();
super::export::ExportManager::handle(ctx).update(ctx, |export_manager, ctx| {
export_manager.export(window_id, &[*cloud_object_type_and_id], ctx);
});
}
#[cfg(not(feature = "local_fs"))]
DriveIndexEvent::ExportObject(_cloud_object_type_and_id) => {
// No-op when no local filesystem.
}
DriveIndexEvent::OpenTeamSettingsPage => {
ctx.emit(DrivePanelEvent::OpenTeamSettingsPage)
}
DriveIndexEvent::RunObject(id) => {
let cloud_model = CloudModel::as_ref(ctx);
let object = cloud_model.get_by_uid(&id.uid());
if let Some(cloud_object) = object {
let workflow: Option<&CloudWorkflow> = cloud_object.into();
let env_var_collection: Option<&CloudEnvVarCollection> = cloud_object.into();
if let Some(workflow) = workflow {
self.run_workflow(workflow.clone(), ctx);
} else if let Some(env_var_collection) = env_var_collection {
self.invoke_environment_variables(env_var_collection.clone(), false, ctx);
}
}
}
DriveIndexEvent::OpenWorkflowModalWithNew {
space,
initial_folder_id,
} => self.open_workflow_modal_with_new(ctx, *space, *initial_folder_id),
DriveIndexEvent::OpenWorkflowModalWithCloudWorkflow(workflow_id) => {
self.open_workflow_modal_with_existing(*workflow_id, ctx)
}
DriveIndexEvent::FocusWarpDrive => ctx.emit(DrivePanelEvent::FocusWarpDrive),
DriveIndexEvent::OpenSharedObjectsCreationDeniedModal(object_type, team_uid) => ctx
.emit(DrivePanelEvent::OpenSharedObjectsCreationDeniedModal(
*object_type,
*team_uid,
)),
DriveIndexEvent::InvokeEnvVarCollectionInSubshell(id) => {
let cloud_model = CloudModel::as_ref(ctx);
let object = cloud_model.get_by_uid(&id.uid());
if let Some(cloud_object) = object {
let env_var_collection: Option<&CloudEnvVarCollection> = cloud_object.into();
if let Some(env_var_collection) = env_var_collection {
self.invoke_environment_variables(env_var_collection.clone(), true, ctx);
}
}
}
DriveIndexEvent::CreateAIFact {
space,
fact,
initial_folder_id,
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
Some(owner) => {
let client_id = ClientId::default();
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_object(
CloudAIFactModel::new(fact.clone()),
owner,
client_id,
CloudObjectEventEntrypoint::Blocklist,
true,
*initial_folder_id,
// When adding the initiated_by parameter to this function call, InitiatedBy::User was set as a default value.
// It can be changed to InitiatedBy::System if this action was automatically kicked off and does not require toasts to notify the user of completion.
InitiatedBy::User,
ctx,
);
});
}
None => {
log::error!("Cannot identify an AI rule owner from {space:?}");
}
},
DriveIndexEvent::AttachPlanAsContext(id) => {
ctx.emit(DrivePanelEvent::AttachPlanAsContext(*id))
}
}
}
fn duplicate_object(
&mut self,
cloud_object_type_and_id: &CloudObjectTypeAndId,
ctx: &mut ViewContext<Self>,
) {
// Check if object being duplicated is in team space, if it is, then check
// corresponding object limits for that team.
if let Some(space) =
CloudViewModel::as_ref(ctx).object_space(&cloud_object_type_and_id.uid(), ctx)
{
match space {
Space::Team { team_uid } => {
match cloud_object_type_and_id {
CloudObjectTypeAndId::Notebook(_) => {
if !UserWorkspaces::has_capacity_for_shared_notebooks(team_uid, ctx, 1)
{
// If team has reached the limit for notebooks, show the modal
// and return early.
ctx.emit(DrivePanelEvent::OpenSharedObjectsCreationDeniedModal(
DriveObjectType::Notebook {
is_ai_document: false,
},
team_uid,
));
return;
}
}
CloudObjectTypeAndId::Workflow(_) => {
if !UserWorkspaces::has_capacity_for_shared_workflows(team_uid, ctx, 1)
{
// If team has reached the limit for workflows, show the modal
// and return early.
ctx.emit(DrivePanelEvent::OpenSharedObjectsCreationDeniedModal(
DriveObjectType::Workflow,
team_uid,
));
return;
}
}
_ => (),
}
}
Space::Personal => match cloud_object_type_and_id {
CloudObjectTypeAndId::Notebook(_) => {
if has_feature_gated_anonymous_user_reached_notebook_limit(ctx) {
return;
}
}
CloudObjectTypeAndId::Workflow(_) => {
if has_feature_gated_anonymous_user_reached_workflow_limit(ctx) {
return;
}
}
CloudObjectTypeAndId::GenericStringObject {
object_type:
GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
id: _,
} => {
if has_feature_gated_anonymous_user_reached_env_var_limit(ctx) {
return;
}
}
_ => {}
},
// We're reliant on server checks here, since the client doesn't know how many
// objects are in the owning drive.
Space::Shared => (),
}
}
// Otherwise allow object duplication to go through.
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.duplicate_object(cloud_object_type_and_id, ctx);
});
ctx.notify();
}
/// Sets the child view back to a default state
fn save_and_clear_child_view(&mut self, ctx: &mut ViewContext<Self>) {
self.reset_all_menus(ctx);
}
/// Reset all context menus in all views
fn reset_all_menus(&mut self, ctx: &mut ViewContext<Self>) {
self.index_view.update(ctx, |index_view, ctx| {
index_view.reset_and_open_to_main_index(ctx);
index_view.reset_menus(ctx);
});
}
pub fn move_object_to_team_owner(
&mut self,
cloud_object_type_and_id: CloudObjectTypeAndId,
space: Space,
ctx: &mut ViewContext<Self>,
) {
self.index_view.update(ctx, |index_view, ctx| {
index_view.move_object_to_team_owner(&cloud_object_type_and_id, space, ctx);
})
}
pub fn set_selected_object(
&mut self,
id: Option<WarpDriveItemId>,
ctx: &mut ViewContext<Self>,
) {
self.index_view.update(ctx, |index_view, ctx| {
index_view.set_selected_object(id, ctx);
});
}
pub fn run_workflow(&mut self, workflow: CloudWorkflow, ctx: &mut ViewContext<Self>) {
ctx.emit(DrivePanelEvent::RunWorkflow(Box::new(workflow)));
ctx.notify();
}
pub fn invoke_environment_variables(
&mut self,
env_var_collection: CloudEnvVarCollection,
in_subshell: bool,
ctx: &mut ViewContext<Self>,
) {
ctx.emit(DrivePanelEvent::InvokeEnvironmentVariables {
env_var_collection: Box::new(env_var_collection),
in_subshell,
});
ctx.notify();
}
pub fn open_existing_workflow_in_pane(
&self,
workflow_id: SyncId,
open_mode: WorkflowViewMode,
ctx: &mut ViewContext<Self>,
) {
ctx.emit(DrivePanelEvent::OpenWorkflowInPane(
WorkflowOpenSource::Existing(workflow_id),
open_mode,
));
ctx.notify();
}
pub fn open_workflow_modal_with_new(
&mut self,
ctx: &mut ViewContext<Self>,
space: Space,
initial_folder_id: Option<SyncId>,
) {
ctx.emit(DrivePanelEvent::OpenWorkflowModalWithNew {
space,
initial_folder_id,
});
}
pub fn open_existing_notebook(&self, notebook_id: SyncId, ctx: &mut ViewContext<Self>) {
ctx.emit(DrivePanelEvent::OpenNotebook(NotebookSource::Existing(
notebook_id,
)));
ctx.notify();
}
pub fn open_workflow_modal_with_existing(
&mut self,
workflow_id: SyncId,
ctx: &mut ViewContext<Self>,
) {
ctx.emit(DrivePanelEvent::OpenWorkflowModalWithCloudWorkflow(
workflow_id,
));
ctx.notify();
}
pub fn open_existing_env_var_collection(
&mut self,
env_var_collection_id: SyncId,
ctx: &mut ViewContext<Self>,
) {
ctx.emit(DrivePanelEvent::OpenEnvVarCollection(
EnvVarCollectionSource::Existing(env_var_collection_id),
));
ctx.notify();
}
pub fn open_cloud_object_dialog(
&mut self,
cloud_object_type: DriveObjectType,
space: Space,
initial_folder_id: Option<SyncId>,
ctx: &mut ViewContext<Self>,
) {
self.save_and_clear_child_view(ctx);
self.index_view.update(ctx, |index_view, ctx| {
index_view.handle_action(
&DriveIndexAction::create_object(cloud_object_type, space, initial_folder_id),
ctx,
)
});
ctx.notify();
}
pub fn create_workflow_with_content(
&mut self,
space: Space,
initial_folder_id: Option<SyncId>,
content: String,
is_for_agent_mode: bool,
ctx: &mut ViewContext<Self>,
) {
self.save_and_clear_child_view(ctx);
self.index_view.update(ctx, |index_view, ctx| {
index_view.handle_action(
&DriveIndexAction::CreateWorkflowWithContent {
space,
initial_folder_id,
content,
is_for_agent_mode,
},
ctx,
)
});
ctx.notify();
}
pub fn open_ai_fact_collection_pane(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(DrivePanelEvent::OpenAIFactCollection);
}
pub fn open_mcp_server_collection_pane(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(DrivePanelEvent::OpenMCPServerCollection);
}
/// Recomputes and intializes the section states for the WD Index. This is needed after
/// we directly change anything about the state of the index (such as folders being open/closed).
///
/// This should only be called if we immeidiately need to update and rely on the updated state.
pub fn initialize_drive_section_states(&mut self, ctx: &mut ViewContext<Self>) {
self.index_view.update(ctx, |index, ctx| {
index.initialize_section_states(ctx);
})
}
pub fn expand_section_for_drive_item_id(
&mut self,
item_id: WarpDriveItemId,
ctx: &mut ViewContext<Self>,
) {
self.index_view.update(ctx, |index, ctx| {
index.expand_section_for_drive_item_id(item_id, ctx);
})
}
/// This functions scrolls the relevant Warp Drive item into view.
pub fn scroll_item_into_view(&mut self, item_id: WarpDriveItemId, ctx: &mut ViewContext<Self>) {
self.index_view.update(ctx, |index, ctx| {
index.scroll_item_into_view(item_id, ctx);
})
}
/// This functions sets the index of a focused Warp Drive item.
pub fn set_focused_index(&mut self, focused_index: Option<usize>, ctx: &mut ViewContext<Self>) {
self.index_view.update(ctx, |index, ctx| {
index.set_focused_index(focused_index, true, ctx);
})
}
pub fn set_focused_item(&mut self, item_id: WarpDriveItemId, ctx: &mut ViewContext<Self>) {
self.index_view.update(ctx, |index, ctx| {
ctx.focus(&self.index_view);
index.set_focused_item(item_id, true, ctx);
})
}
pub fn open_object_sharing_settings(
&mut self,
object_id: CloudObjectTypeAndId,
invitee_email: Option<String>,
source: SharingDialogSource,
ctx: &mut ViewContext<Self>,
) {
let warp_drive_item_id = WarpDriveItemId::Object(object_id);
self.index_view.update(ctx, |index, ctx| {
index.set_focused_item(warp_drive_item_id, true, ctx);
index.toggle_share_dialog(&warp_drive_item_id, invitee_email, source, ctx);
});
}
pub fn has_warp_drive_initialized_sections(
&self,
app: &AppContext,
) -> impl Future<Output = ()> {
self.index_view.as_ref(app).has_initialized_sections()
}
pub fn reset_focused_index_in_warp_drive(
&mut self,
should_scroll: bool,
ctx: &mut ViewContext<Self>,
) {
self.index_view.update(ctx, |index, ctx| {
index.reset_focused_index_in_warp_drive(should_scroll, ctx);
})
}
pub fn reset_and_open_to_main_index(&mut self, ctx: &mut ViewContext<Self>) {
self.index_view.update(ctx, |index, ctx| {
index.reset_and_open_to_main_index(ctx);
})
}
pub fn undo_trash(
&mut self,
cloud_object_type_and_id: &CloudObjectTypeAndId,
ctx: &mut ViewContext<Self>,
) {
self.index_view.update(ctx, |index_view, ctx| {
index_view.untrash_object(cloud_object_type_and_id, ctx)
});
}
}
impl View for DrivePanel {
fn ui_name() -> &'static str {
"WarpDrivePanel"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.index_view);
}
}
fn render(&self, _app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let body = Hoverable::new(
self.mouse_state_handles.focus_panel_mouse_state.clone(),
|_| {
Align::new(
SavePosition::new(
ChildView::new(&self.index_view).finish(),
WARP_DRIVE_POSITION_ID,
)
.finish(),
)
.top_center()
.finish()
},
)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(DrivePanelAction::FocusDriveIndex);
})
.finish();
let mut col = Flex::column();
col.add_child(Shrinkable::new(1., body).finish());
col.with_main_axis_size(warpui::elements::MainAxisSize::Max)
.finish()
}
}
impl Entity for DrivePanel {
type Event = DrivePanelEvent;
}
impl TypedActionView for DrivePanel {
type Action = DrivePanelAction;
fn handle_action(&mut self, action: &DrivePanelAction, ctx: &mut ViewContext<Self>) {
match action {
DrivePanelAction::OpenSearch => ctx.emit(DrivePanelEvent::OpenSearch),
DrivePanelAction::FocusDriveIndex => {
ctx.focus(&self.index_view);
// should_scroll is set to false here in order to not let menu clicks autoscroll WD index
self.reset_focused_index_in_warp_drive(false, ctx);
}
}
}
}
pub(crate) mod styles {
/// Right padding between the search button and the close button.
pub const SEARCH_BUTTON_PADDING_RIGHT: f32 = 4.;
}
#[cfg(test)]
#[path = "panel_test.rs"]
mod tests;
+71
View File
@@ -0,0 +1,71 @@
use warp_core::ui::appearance::Appearance;
use warpui::{platform::WindowStyle, App};
use crate::{
ai::blocklist::BlocklistAIHistoryModel,
auth::{auth_manager::AuthManager, AuthStateProvider},
cloud_object::{
model::{persistence::CloudModel, view::CloudViewModel},
Space,
},
drive::index::DriveIndexSection,
network::NetworkStatus,
server::{
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
sync_queue::SyncQueue, telemetry::context_provider::AppTelemetryContextProvider,
},
settings_view::keybindings::KeybindingChangedNotifier,
terminal::{
resizable_data::ResizableData,
shared_session::permissions_manager::SessionPermissionsManager,
},
test_util::settings::initialize_settings_for_tests,
workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces},
Assets, ObjectActions,
};
use super::DrivePanel;
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(|_| ResizableData::default());
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudViewModel::mock);
app.add_singleton_model(|_| ObjectActions::new(Vec::new()));
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
app.add_singleton_model(AuthManager::new_for_test);
app.add_singleton_model(SessionPermissionsManager::new);
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
#[cfg(feature = "voice_input")]
app.add_singleton_model(voice_input::VoiceInput::new);
}
#[test]
fn test_warp_drive_sections_with_no_team() {
App::test(Assets, |mut app| async move {
initialize_app(&mut app);
// Instead of being in the panel module and depending on DrivePanel, this test should be in the index module.
// It happens to be here for the time being because `DriveIndex` depends on `DrivePanel` calling the `initialize_section_states` method.
// Ideally, the constructor should handle the necessary initialization but for now this functional test asserts that the drive index is setup.
let (_, panel) = app.add_window(WindowStyle::NotStealFocus, DrivePanel::new);
let index = panel.read(&app, |panel, _| panel.index_view.clone());
index.read(&app, |index, _| {
let sections = index.sections();
assert_eq!(sections.len(), 2);
assert_eq!(sections[0], DriveIndexSection::CreateATeam);
assert_eq!(sections[1], DriveIndexSection::Space(Space::Personal))
});
})
}
+51
View File
@@ -0,0 +1,51 @@
use settings::{
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
};
use warp_core::features::FeatureFlag;
use super::DriveSortOrder;
pub const HAS_AUTO_OPENED_WELCOME_FOLDER: &str = "HasAutoOpenedWelcomeFolder";
define_settings_group!(WarpDriveSettings, settings: [
sorting_choice: WarpDriveSortingChoice {
type: DriveSortOrder,
default: DriveSortOrder::ByObjectType,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "warp_drive.sorting_choice",
description: "The sort order for items in Warp Drive.",
},
sharing_onboarding_block_shown: WarpDriveSharingOnboardingBlockShown {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
},
// Controls whether Warp Drive appears in the tools panel, command palette, and command search.
enable_warp_drive: EnableWarpDrive {
type: bool,
default: true,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "warp_drive.enabled",
description: "Whether Warp Drive is enabled.",
},
]);
impl WarpDriveSettings {
/// Returns whether Warp Drive should be considered enabled.
/// Returns `false` when the user is anonymous or fully logged out,
/// regardless of the user setting.
pub fn is_warp_drive_enabled(app: &warpui::AppContext) -> bool {
use warpui::SingletonEntity as _;
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
&& crate::auth::AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out();
*Self::as_ref(app).enable_warp_drive && !is_anonymous_or_logged_out
}
}
@@ -0,0 +1,99 @@
//! Support for displaying inherited ACLs.
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{CrossAxisAlignment, Flex, MouseStateHandle, ParentElement as _},
ui_components::components::UiComponent as _,
AppContext, Element, SingletonEntity as _,
};
use super::style;
use crate::{
cloud_object::{model::persistence::CloudModel, ServerObjectContainer},
drive::CloudObjectTypeAndId,
server::{ids::SyncId, telemetry::SharingDialogSource},
workspace::WorkspaceAction,
};
/// UI state for inherited permissions.
pub struct InheritanceState {
// The server API allows inheriting ACLs from drives as well, but we currently don't use this.
source_folder: SyncId,
link_handle: MouseStateHandle,
}
impl InheritanceState {
/// Construct inheritance state for an object and the source of its possibly-inherited ACL.
pub fn from_object_and_source(
object_id: &SyncId,
source: Option<&ServerObjectContainer>,
) -> Option<InheritanceState> {
let source_folder = match source? {
ServerObjectContainer::Folder { folder_uid } => SyncId::ServerId(*folder_uid),
_ => return None,
};
// ACLs _on_ folders may include themselves as sources.
if &source_folder == object_id {
return None;
}
Some(InheritanceState {
source_folder,
link_handle: Default::default(),
})
}
pub fn details(&self, appearance: &Appearance, app: &AppContext) -> InheritanceDetails {
let folder_name = CloudModel::as_ref(app)
.get_folder(&self.source_folder)
.map(|folder| &folder.model().name);
match folder_name {
Some(folder_name) => {
let prefix = style::detail_text("Inherited from ", appearance)
.build()
.finish();
let source_folder = self.source_folder;
let folder_link = appearance
.ui_builder()
.link(
folder_name.to_owned(),
None,
Some(Box::new(move |ctx| {
ctx.dispatch_typed_action(WorkspaceAction::OpenObjectSharingSettings {
object_id: CloudObjectTypeAndId::Folder(source_folder),
source: SharingDialogSource::InheritedPermission,
});
})),
self.link_handle.clone(),
)
.soft_wrap(false)
.build()
.finish();
InheritanceDetails {
source_label: Flex::row()
.with_children([prefix, folder_link])
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
tooltip_text: "Edit inherited permissions on the parent folder",
}
}
None => InheritanceDetails {
source_label: style::detail_text("Inherited permission", appearance)
.build()
.finish(),
tooltip_text: "Cannot edit inherited permissions",
},
}
}
}
/// Information to display about inherited permissions.
pub struct InheritanceDetails {
/// A label element describing where an ACL was inherited from, with a link to edit those
/// permissions directly.
pub source_label: Box<dyn Element>,
/// A tooltip to show on disabled permission-editing controls.
pub tooltip_text: &'static str,
}
File diff suppressed because it is too large Load Diff
+284
View File
@@ -0,0 +1,284 @@
use std::borrow::Cow;
use chrono::{DateTime, Local};
use session_sharing_protocol::common::SessionId;
use warp_core::{channel::ChannelState, ui::appearance::Appearance};
use warpui::{
color::ColorU,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, SingletonEntity, WeakViewHandle,
};
use crate::{
ai::{agent::conversation::AIConversationId, blocklist::BlocklistAIHistoryModel},
cloud_object::model::persistence::CloudModel,
server::{ids::ServerId, server_api::object::GuestIdentifier},
terminal::{shared_session::join_link, TerminalView},
ui_components::{
avatar::{Avatar, AvatarContent},
icons::Icon,
},
workspaces::{user_profiles::UserProfiles, user_workspaces::UserWorkspaces},
};
pub mod dialog;
mod style;
// Re-export types from warp_server_client.
pub use warp_server_client::drive::sharing::{
LinkSharingSubjectType, SharingAccessLevel, Subject, TeamKind, UserKind,
};
/// Identifier for an object that's shareable via the Warp Drive ACL model. Not all sharing in Warp
/// is _currently_ tied into this model (e.g. block sharing).
#[derive(Debug, Clone)]
pub enum ShareableObject {
/// A shareable Warp Drive object.
WarpDriveObject(ServerId),
/// A shared terminal session. Shared sessions are identified by the participating terminal
/// pane.
Session {
handle: WeakViewHandle<TerminalView>,
session_id: SessionId,
started_at: DateTime<Local>,
},
/// An AI conversation.
AIConversation(AIConversationId),
}
impl ShareableObject {
/// The canonical link to this object.
pub fn link(&self, app: &AppContext) -> Option<String> {
match self {
ShareableObject::WarpDriveObject(id) => CloudModel::as_ref(app)
.get_by_uid(&id.uid())
.and_then(|object| object.object_link()),
ShareableObject::Session { session_id, .. } => Some(join_link(session_id)),
ShareableObject::AIConversation(id) => {
// Use the unified helper that checks both loaded conversation and historical metadata
BlocklistAIHistoryModel::as_ref(app)
.get_server_conversation_metadata(id)
.map(|m| {
format!(
"{}/conversation/{}",
ChannelState::server_root_url(),
m.server_conversation_token.as_str()
)
})
}
}
}
}
/// Whether not a shared object's contents are editable by the current user.
///
/// This is not purely a function of their access level since anonymous users are not allowed to
/// edit (due to the lack of attribution).
#[derive(Debug, Clone, Copy)]
pub enum ContentEditability {
ReadOnly,
RequiresLogin,
Editable,
}
impl ContentEditability {
pub fn can_edit(self) -> bool {
matches!(self, ContentEditability::Editable)
}
}
/// Extension trait for Subject with methods that require AppContext.
pub trait SubjectExt {
/// The name of this subject.
fn name(&self, app: &AppContext) -> Option<Cow<'static, str>>;
/// Detail text to display under this subject's name.
fn detail(&self, app: &AppContext) -> Option<String>;
/// Avatar component to show for this subject.
fn avatar(&self, appearance: &Appearance, app: &AppContext) -> Avatar;
/// Gets the email address for this subject, if it has one.
fn email<'a>(&'a self, app: &'a AppContext) -> Option<&'a str>;
/// Checks if this subject refers to the same user as an email address.
fn matches_email(&self, email: &str, app: &AppContext) -> bool;
/// Converts this subject to a [`GuestIdentifier`] for guest removal.
/// Returns `Some` for team or user subjects (that have an email), `None` otherwise.
fn to_guest_identifier(&self, app: &AppContext) -> Option<GuestIdentifier>;
}
impl SubjectExt for Subject {
fn name(&self, app: &AppContext) -> Option<Cow<'static, str>> {
match self {
Subject::User(kind) => kind.name(app),
Subject::PendingUser { email } => email.clone().map(Cow::from),
Subject::Team(kind) => kind.display_name(app).map(Cow::from),
Subject::AnyoneWithLink(_) => Some(Cow::from("Anyone with the link")),
}
}
fn detail(&self, app: &AppContext) -> Option<String> {
if let Subject::User(kind) = self {
kind.detail(app)
} else {
None
}
}
fn avatar(&self, appearance: &Appearance, app: &AppContext) -> Avatar {
match self {
Subject::User(kind) => named_subject_avatar(kind.avatar_content(app), appearance),
Subject::PendingUser { email } => named_subject_avatar(
AvatarContent::DisplayName(email.clone().unwrap_or_default()),
appearance,
),
Subject::Team(_) => icon_avatar(Icon::Users, appearance),
Subject::AnyoneWithLink(subject_type) => {
let icon = match subject_type {
LinkSharingSubjectType::Anyone => Icon::Globe,
LinkSharingSubjectType::None => Icon::Lock,
};
icon_avatar(icon, appearance)
}
}
}
fn email<'a>(&'a self, app: &'a AppContext) -> Option<&'a str> {
match self {
Subject::User(user_kind) => match user_kind {
UserKind::Account(user_uid) => UserProfiles::as_ref(app)
.profile_for_uid(*user_uid)
.map(|profile| profile.email.as_str()),
UserKind::SharedSessionParticipant(profile_data) => profile_data.email.as_deref(),
},
Subject::PendingUser { email } => email.as_deref(),
Subject::Team(_) => None,
Subject::AnyoneWithLink(_) => None,
}
}
fn matches_email(&self, email: &str, app: &AppContext) -> bool {
self.email(app)
.is_some_and(|subject_email| subject_email == email)
}
fn to_guest_identifier(&self, app: &AppContext) -> Option<GuestIdentifier> {
if let Some(team_uid) = self.team_uid() {
return Some(GuestIdentifier::TeamUid(team_uid));
}
if let Some(email) = self.email(app) {
return Some(GuestIdentifier::Email(email.to_owned()));
}
None
}
}
/// Extension trait for UserKind with methods that require AppContext.
pub trait UserKindExt {
/// Gets the display name for this user kind.
fn name(&self, app: &AppContext) -> Option<Cow<'static, str>>;
/// Detail text to display under this user's name.
fn detail(&self, app: &AppContext) -> Option<String>;
/// Avatar content for this user kind.
fn avatar_content(&self, app: &AppContext) -> AvatarContent;
}
impl UserKindExt for UserKind {
fn name(&self, app: &AppContext) -> Option<Cow<'static, str>> {
match self {
UserKind::Account(id) => UserProfiles::as_ref(app)
.displayable_identifier_for_uid(*id)
.map(Cow::from),
UserKind::SharedSessionParticipant(participant_info) => {
Some(participant_info.display_name.clone().into())
}
}
}
fn detail(&self, app: &AppContext) -> Option<String> {
match self {
UserKind::Account(uid) => {
let profile = UserProfiles::as_ref(app).profile_for_uid(*uid)?;
// Only show the user's email if we're already showing a display name.
if profile.display_name.is_some() {
Some(profile.email.clone())
} else {
None
}
}
UserKind::SharedSessionParticipant(participant_info) => {
// Only show the user's email if it's not the display name.
if participant_info
.email
.as_ref()
.is_some_and(|email| email == &participant_info.display_name)
{
None
} else {
participant_info.email.clone()
}
}
}
}
fn avatar_content(&self, app: &AppContext) -> AvatarContent {
match self {
UserKind::Account(uid) => match UserProfiles::as_ref(app).profile_for_uid(*uid) {
Some(profile) => AvatarContent::Image {
url: profile.photo_url.clone(),
display_name: profile.displayable_identifier(),
},
None => AvatarContent::DisplayName(String::new()),
},
UserKind::SharedSessionParticipant(participant_info) => {
match &participant_info.photo_url {
Some(url) => AvatarContent::Image {
url: url.clone(),
display_name: participant_info.display_name.clone(),
},
None => AvatarContent::DisplayName(participant_info.display_name.clone()),
}
}
}
}
}
/// Extension trait for TeamKind with methods that require AppContext.
pub trait TeamKindExt {
/// Gets the display name for this team kind.
fn display_name(&self, app: &AppContext) -> Option<String>;
}
impl TeamKindExt for TeamKind {
fn display_name(&self, app: &AppContext) -> Option<String> {
match self {
TeamKind::Team { team_uid, .. } => UserWorkspaces::as_ref(app)
.team_from_uid(*team_uid)
.map(|team| team.name.clone()),
TeamKind::SharedSessionTeam { name, .. } => Some(name.clone()),
}
}
}
/// Helper to build an [Avatar] that shows a named subject.
fn named_subject_avatar(content: AvatarContent, appearance: &Appearance) -> Avatar {
Avatar::new(
content,
UiComponentStyles {
// TODO: Apply session-sharing color logic.
background: Some(ColorU::new(93, 202, 60, 255).into()),
font_color: Some(ColorU::black()),
..Default::default()
},
)
.with_style(style::subject_avatar_styles(appearance))
}
/// Helper to build an [Avatar] that shows a subject icon.
fn icon_avatar(icon: Icon, appearance: &Appearance) -> Avatar {
Avatar::new(
AvatarContent::Icon(icon),
UiComponentStyles {
font_color: Some(style::acl_secondary_text_color(appearance)),
..Default::default()
},
)
.with_style(style::subject_avatar_styles(appearance))
}
+89
View File
@@ -0,0 +1,89 @@
use std::borrow::Cow;
use warp_core::ui::{
appearance::Appearance,
theme::{color::internal_colors, Fill},
};
use warpui::{
color::ColorU,
elements::{CornerRadius, Radius},
fonts::Weight,
ui_components::{
components::{UiComponent as _, UiComponentStyles},
text::Span,
},
};
/// The padding around ACL items in the sharing dialog.
pub const ACL_ITEM_PADDING: f32 = 16.;
/// The gap between ACL items in the sharing dialog. Because the UI framework doesn't support gaps,
/// items should generally have vertical margins of `ACL_ITEM_GAP / 2`.
pub const ACL_ITEM_GAP: f32 = 10.;
/// The height of ACL items, not including spacing.
pub const ACL_ITEM_HEIGHT: f32 = 32.;
/// The height for guest ACL items, which is slightly larger than [`ACL_ITEM_HEIGHT`] to account
/// for guest details.
pub const ACL_GUEST_HEIGHT: f32 = 36.;
/// The font size for primary text in the dialog, like subject names.
pub const PRIMARY_TEXT_SIZE: f32 = 14.;
/// The font size for header text in the dialog.
pub const HEADER_TEXT_SIZE: f32 = 16.;
/// Background color for the sharing dialog.
pub fn dialog_background(appearance: &Appearance) -> ColorU {
appearance.theme().surface_1().into_solid()
}
/// Text color for primary ACL information.
pub fn acl_primary_text_color(appearance: &Appearance) -> ColorU {
internal_colors::text_main(appearance.theme(), dialog_background(appearance))
}
/// Text color for secondary ACL information.
pub fn acl_secondary_text_color(appearance: &Appearance) -> ColorU {
internal_colors::text_sub(appearance.theme(), dialog_background(appearance))
}
/// Text color for non-interactive labels.
pub fn label_text(appearance: &Appearance) -> ColorU {
internal_colors::text_disabled(appearance.theme(), dialog_background(appearance))
}
/// Fill to use for borders around form-like text.
pub fn form_border_color(appearance: &Appearance) -> ColorU {
appearance.theme().surface_3().into_solid()
}
/// Background to use for chip-like form elements.
pub fn form_chip_background(appearance: &Appearance) -> Fill {
appearance.theme().surface_2()
}
/// Common ACL avatar styles.
pub fn subject_avatar_styles(appearance: &Appearance) -> UiComponentStyles {
UiComponentStyles {
width: Some(24.),
height: Some(24.),
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
font_size: Some(appearance.ui_font_size()),
font_family_id: Some(appearance.ui_font_family()),
font_weight: Some(Weight::Bold),
..Default::default()
}
}
/// Create a detail-text span.
pub fn detail_text(text: impl Into<Cow<'static, str>>, appearance: &Appearance) -> Span {
appearance
.ui_builder()
.span(text)
.with_style(UiComponentStyles {
font_color: Some(acl_secondary_text_color(appearance)),
..Default::default()
})
}
+259
View File
@@ -0,0 +1,259 @@
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use warp_graphql::mutations::generate_metadata_for_command::{
GenerateMetadataForCommandFailureType, GenerateMetadataForCommandSuccess,
};
use warpui::{SingletonEntity, ViewContext};
use crate::{
ai::AIRequestUsageModel,
auth::AuthStateProvider,
send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
workflows::workflow::{Argument, Workflow},
workspaces::user_workspaces::UserWorkspaces,
};
use super::{
arguments::ArgumentsState,
modal::{AiAssistState, WorkflowModal, WorkflowModalEvent},
};
/// Generated command metadata from server.
#[derive(Debug)]
pub struct GeneratedCommandMetadata {
pub command: String,
pub title: String,
pub description: String,
pub arguments: Vec<GeneratedArgument>,
}
/// Metadata for a parameter in the workflow.
#[derive(Debug)]
pub struct GeneratedArgument {
pub name: String,
pub description: String,
pub default_value: String,
}
impl From<GenerateMetadataForCommandSuccess> for GeneratedCommandMetadata {
fn from(value: GenerateMetadataForCommandSuccess) -> Self {
GeneratedCommandMetadata {
command: value.parameterized_command,
title: value.title,
description: value.description,
arguments: value
.parameters
.into_iter()
.map(|p| GeneratedArgument {
name: p.name,
description: p.description,
default_value: p.value,
})
.collect_vec(),
}
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
pub enum GeneratedCommandMetadataError {
/// OpenAI failed to generate a parsable response.
BadCommand,
/// Request to OpenAI failed
AiProviderError,
/// User is over rate limit.
RateLimited,
Other,
}
impl GeneratedCommandMetadataError {
pub fn user_facing_message(&self) -> String {
match self {
Self::BadCommand => {
"Failed to generate metadata. Please try again with a different command."
}
Self::AiProviderError => "Something went wrong. Please try again.",
Self::RateLimited => "Looks like you're out of AI credits. Please try again later.",
Self::Other => "Something went wrong. Please try again.",
}
.to_string()
}
}
impl From<GenerateMetadataForCommandFailureType> for GeneratedCommandMetadataError {
fn from(value: GenerateMetadataForCommandFailureType) -> Self {
match value {
GenerateMetadataForCommandFailureType::BadCommand => Self::BadCommand,
GenerateMetadataForCommandFailureType::AiProviderError => Self::AiProviderError,
GenerateMetadataForCommandFailureType::RateLimited => Self::RateLimited,
GenerateMetadataForCommandFailureType::Other => Self::Other,
}
}
}
impl WorkflowModal {
/// Send request to generate metadata for the command in command editor.
pub(super) fn issue_request(&mut self, ctx: &mut ViewContext<Self>) {
let ai_client = self.ai_client.clone();
let content = self.content_editor.as_ref(ctx).buffer_text(ctx);
let raw_request = content.trim().to_string();
ctx.spawn(
async move { ai_client.generate_metadata_for_command(raw_request).await },
move |modal, response, ctx| {
match response {
Ok(metadata) => {
modal.ai_metadata_assist_state = AiAssistState::Generated;
modal.enable_editors(ctx);
let arguments = metadata
.arguments
.into_iter()
.map(|parameter| Argument {
name: parameter.name,
description: Some(parameter.description),
default_value: Some(parameter.default_value),
arg_type: Default::default()
})
.collect_vec();
let workflow = Workflow::Command {
name: metadata.title,
description: Some(metadata.description),
command: metadata.command,
arguments,
tags: vec![],
source_url: None,
author: None,
author_url: None,
shells: vec![],
environment_variables: None,
};
send_telemetry_from_ctx!(
TelemetryEvent::AutoGenerateMetadataSuccess,
ctx
);
modal.populate_missing_field_with_suggestion(workflow, ctx);
ctx.notify();
}
Err(err) => {
let message = err.user_facing_message();
if let GeneratedCommandMetadataError::RateLimited = err {
let auth_state = AuthStateProvider::as_ref(ctx).get();
let current_user_id = auth_state.user_id().unwrap_or_default();
if let Some(team) = UserWorkspaces::as_ref(ctx).current_team() {
let current_user_email =
auth_state.user_email().unwrap_or_default();
let has_admin_permissions = team.has_admin_permissions(&current_user_email);
if team.billing_metadata.can_upgrade_to_higher_tier_plan() {
if has_admin_permissions {
ctx.emit(WorkflowModalEvent::AiAssistUpgradeError(Some(team.uid), current_user_id));
} else {
ctx.emit(WorkflowModalEvent::AiAssistError("Looks like you're out of AI credits. Contact a team admin to upgrade for more credits.".to_string()));
}
} else {
ctx.emit(WorkflowModalEvent::AiAssistError(message.clone()));
}
} else {
ctx.emit(WorkflowModalEvent::AiAssistUpgradeError(None, current_user_id));
}
} else {
ctx.emit(WorkflowModalEvent::AiAssistError(message.clone()));
}
send_telemetry_from_ctx!(
TelemetryEvent::AutoGenerateMetadataError {
error_payload: serde_json::json!(err)
},
ctx
);
modal.ai_metadata_assist_state = AiAssistState::PreRequest;
modal.enable_editors(ctx);
ctx.notify();
}
}
AIRequestUsageModel::handle(ctx).update(ctx, |request_usage_model, ctx| {
request_usage_model.refresh_request_usage_async(ctx);
});
}
);
self.ai_metadata_assist_state = AiAssistState::RequestInFlight;
self.disable_editors(ctx);
ctx.notify();
}
// Populate only the missing field in the workflow editor with the generated suggestion from AI.
pub(super) fn populate_missing_field_with_suggestion(
&mut self,
workflow: Workflow,
ctx: &mut ViewContext<Self>,
) {
self.title_editor.update(ctx, |editor, ctx| {
if editor.is_empty(ctx) {
editor.set_buffer_text(workflow.name(), ctx);
}
});
self.description_editor.update(ctx, |editor, ctx| {
if editor.is_empty(ctx) {
editor.set_buffer_text(
workflow
.description()
.map(String::as_str)
.unwrap_or_default(),
ctx,
);
}
});
let content_parsed = !self.arguments_state.arguments.is_empty();
if !content_parsed {
self.content_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(workflow.content(), ctx);
});
// note: normally, we wouldn't have to do this, since editing the command
// editor's text will trigger the event that does this automatically.
// however, that happens in a callback, yet we need to know what the args
// are right away to populate the description/default value editors.
self.arguments_state = ArgumentsState::for_command_workflow(
&self.arguments_state,
workflow.content().to_string(),
);
self.update_arguments_rows(ctx);
workflow
.arguments()
.iter()
.enumerate()
.for_each(|(index, argument)| {
// Since suggestion generated by AI is non-deterministic, we should make sure to handle each
// operation safely.
if index >= self.arguments_rows.len() {
return;
}
if let Some(description) = &argument.description {
self.arguments_rows[index]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text(description.as_str(), ctx);
});
}
if let Some(default_value) = &argument.default_value {
self.arguments_rows[index].default_value_editor.update(
ctx,
|editor, ctx| {
editor.set_buffer_text(default_value.as_str(), ctx);
},
);
}
});
}
}
}
+205
View File
@@ -0,0 +1,205 @@
use std::{
collections::{HashMap, HashSet},
ops::Range,
};
use crate::workflows::workflow::Argument;
use handlebars::parser::{ParsedArgumentResult, ParsedArgumentsIterator};
/// Represents arguments for workflow to be viewed and edited in ArgumentsEditorView.
///
/// ArgumentsState contains the current state of arguments, and a constructor `::from_string`
/// which will use the remaining ArgumentsState data to connect at some state "k" to next
/// state "k + 1", generated from the next edit. This is necessary to identify how arguments shift
/// and retain existing description and default values; previous arguments are matched either by a
/// query by word index or query by name. (See constructor method for further discusssion.)
#[derive(Debug, Default)]
pub struct ArgumentsState {
pub arguments: Vec<Argument>,
/// Hashmap mapping the word index in the input command string to (index) in the
/// arguments vector. Each index of the arguments vector should be a hashmap value;
/// only word indexes with arguments (from input string) should be a hashmap key.
/// Enables `query_argument_by_name`.
word_index_to_arg_index_map: HashMap<usize, usize>,
/// Hashmap mapping the argument name in the input command string to (index) in the
/// arguments vector. Enables `query_argument_by_word_index`.
arg_name_to_arg_index_map: HashMap<String, usize>,
number_of_words: usize,
pub invalid_arguments_char_ranges: Vec<Range<usize>>,
pub valid_arguments_char_ranges_and_arg_index: Vec<(Range<usize>, usize)>,
}
impl ArgumentsState {
/// The `::from_string` constructor connects the previous arguments state to the new state and
/// to retain some descriptions and default values. This is needed because the command string is
/// regex-ed every edit in `ArgumentsEditorView.update_command`; with default `None` values,
/// the descriptions and default values are cleared.
///
/// Reasonably, a user expects data from arguments that they did not directly edit to remain intact.
/// This approach improves handling by indexing each word (as defined by whitespace, `{{`, or `}}`).
/// If the number of words is the same, an argument in the new formed string will query for an argument
/// with the same word index in the previous state. If the number of words is not the same, an argument
/// in the new formed string will query for an argument with the same argument name in the previous state.
/// In both cases, if an argument is found, the new argument will retain the previous description and
/// default value. Otherwise, both values default to None.
///
/// Arguments are shown, if valid (see `ParsedArgumentsIterator`), in order of first occurrence with no duplicates.
/// The word index points to the first occurrence of the argument. For insertion/deletion (+/- number of words),
/// arguments' descriptions and default values will re-arrange with argument names. For edits (no word delta),
/// the modified occurrence will retain its description and default value, its former duplicates will not.
///
/// eg. Given workflow `ls {{argument_1}} {{argument_2}} {{argument_3}}` which is then edited to
/// `ls {{argument_1}} {{argument_1}} {{argument_2}} {{argument_3}}`. The number of words has changed
/// so we connect arguments to previous values using a by_name search.
///
/// If the edited result is instead `ls {{argument_10}} {{argument_2}} {{argument_3}}`, the number of
/// words did not change, and so we use a by_word_index search (which will argument_10 to argument_1, etc.).
pub fn for_command_workflow(prev_state: &ArgumentsState, input_string: String) -> Self {
Self::new(prev_state, input_string, false)
}
pub fn for_saved_prompt(prev_state: &ArgumentsState, input_string: String) -> Self {
Self::new(prev_state, input_string, true)
}
fn new(prev_state: &ArgumentsState, input_string: String, is_for_saved_prompt: bool) -> Self {
let mut arg_name_word_index_pairs: Vec<(String, usize)> = Vec::new();
let mut arg_names = HashSet::new();
let mut valid_arguments_char_ranges_and_name: Vec<(Range<usize>, String)> = Vec::new();
let mut invalid_arguments_char_ranges = Vec::new();
let mut arguments_iterator = ParsedArgumentsIterator::new(input_string.chars());
for argument_result in arguments_iterator.by_ref() {
match argument_result.result() {
ParsedArgumentResult::Valid { current_word_index } => {
let start_char_index = argument_result.chars_range().start;
let argument_name_length = argument_result.chars_range().end - start_char_index;
let argument_name: String = input_string
.chars()
.skip(start_char_index)
.take(argument_name_length)
.collect();
if !arg_names.contains(&argument_name) {
arg_name_word_index_pairs
.push((argument_name.clone(), *current_word_index));
arg_names.insert(argument_name.clone());
}
valid_arguments_char_ranges_and_name
.push((argument_result.chars_range(), argument_name));
}
ParsedArgumentResult::Invalid => {
// We don't care about 'invalid' arguments for saved prompts, since the argument
// might be intentional/valid. For example, a user's saved prompt might contain
// {{.foo}} which isn't intended to be an _argument_.
if !is_for_saved_prompt {
invalid_arguments_char_ranges.push(argument_result.chars_range());
}
}
}
}
let number_of_words = arguments_iterator.word_count();
let (arguments, word_index_to_arg_index_map, arg_name_to_arg_index_map) =
ArgumentsState::build_arguments_and_query_maps(
prev_state,
number_of_words != prev_state.number_of_words,
arg_name_word_index_pairs,
);
let valid_arguments_char_ranges_and_arg_index: Vec<(Range<usize>, usize)> =
valid_arguments_char_ranges_and_name
.iter()
.map(|(range, name)| {
(
range.clone(),
*arg_name_to_arg_index_map
.get(name)
.expect("All valid arguments' names must map to an argument index"),
)
})
.collect();
Self {
arguments,
word_index_to_arg_index_map,
arg_name_to_arg_index_map,
number_of_words,
invalid_arguments_char_ranges,
valid_arguments_char_ranges_and_arg_index,
}
}
fn build_arguments_and_query_maps(
prev_state: &ArgumentsState,
is_insertion_or_deletion: bool,
arg_name_word_index_pairs: Vec<(String, usize)>,
) -> (Vec<Argument>, HashMap<usize, usize>, HashMap<String, usize>) {
let mut word_index_to_arg_index_map = HashMap::new();
let mut arg_name_to_arg_index_map = HashMap::new();
let arguments: Vec<Argument> = arg_name_word_index_pairs
.iter()
.enumerate()
.map(|(arg_index, (name, word_index))| {
let prev_argument = if is_insertion_or_deletion {
prev_state.query_argument_by_name(name)
} else {
prev_state.query_argument_by_word_index(*word_index)
};
let argument: Argument = match prev_argument {
Some(prev_argument) => {
ArgumentsState::new_argument_with_previous_data(name, prev_argument)
}
None => Argument::new(name, Default::default()),
};
word_index_to_arg_index_map.insert(*word_index, arg_index);
arg_name_to_arg_index_map.insert(name.to_string(), arg_index);
argument
})
.collect();
(
arguments,
word_index_to_arg_index_map,
arg_name_to_arg_index_map,
)
}
fn query_argument_by_name(&self, name: &str) -> Option<&Argument> {
match self.arg_name_to_arg_index_map.get(name) {
Some(argument_index) => Some(&self.arguments[*argument_index]),
None => None,
}
}
fn query_argument_by_word_index(&self, word_index: usize) -> Option<&Argument> {
match self.word_index_to_arg_index_map.get(&word_index) {
Some(argument_index) => Some(&self.arguments[*argument_index]),
None => None,
}
}
fn new_argument_with_previous_data(
new_argument_name: &str,
prev_argument: &Argument,
) -> Argument {
Argument {
name: new_argument_name.to_string(),
description: prev_argument.description.clone(),
default_value: prev_argument.default_value.clone(),
arg_type: Default::default(),
}
}
}
#[cfg(test)]
#[path = "arguments_test.rs"]
mod tests;
+296
View File
@@ -0,0 +1,296 @@
use std::{fmt::Debug, iter::zip};
use crate::workflows::workflow::Argument;
use warpui::App;
use super::ArgumentsState;
fn assert_vector_eq<T>(vector: Vec<T>, expected_vector: &Vec<T>)
where
T: Debug + PartialEq,
{
assert_eq!(vector.len(), expected_vector.len());
zip(vector, expected_vector).for_each(|(element, expected)| {
assert_eq!(element, *expected);
})
}
fn build_argument(
name: impl Into<String>,
description: impl Into<Option<String>>,
default_value: impl Into<Option<String>>,
) -> Argument {
Argument {
name: name.into(),
description: description.into(),
default_value: default_value.into(),
arg_type: Default::default(),
}
}
#[test]
fn test_arguments_state_from_string() {
App::test((), |_app| async move {
let empty_args_state: ArgumentsState = Default::default();
let mut args_state = ArgumentsState::for_command_workflow(
&empty_args_state,
"one two{{three}} {{four}}".to_string(),
);
assert_vector_eq(
args_state.arguments.clone(),
&vec![
build_argument("three", None, None),
build_argument("four", None, None),
],
);
// Mutate data in current arguments state object
if let Some(change_index) = args_state.word_index_to_arg_index_map.get(&2) {
if let Some(change_arg) = args_state.arguments.get_mut(*change_index) {
change_arg.description = Some("new desc".to_string());
change_arg.default_value = Some("default value".to_string());
}
}
if let Some(change_index) = args_state.word_index_to_arg_index_map.get(&3) {
if let Some(change_arg) = args_state.arguments.get_mut(*change_index) {
change_arg.description = Some("another desc".to_string());
change_arg.default_value = Some("change default value".to_string());
}
}
// Edits that don't change number of words retain data
let new_args_state = ArgumentsState::for_command_workflow(
&args_state,
"one two {{the}} {{four}}".to_string(),
);
assert_vector_eq(
new_args_state.arguments.clone(),
&vec![
build_argument("the", "new desc".to_string(), "default value".to_string()),
build_argument(
"four",
"another desc".to_string(),
"change default value".to_string(),
),
],
);
// Insertions, retain data from args before and after
let insertion_args_state = ArgumentsState::for_command_workflow(
&new_args_state,
"one two {{the}}{{five}} {{six}}{{four}}".to_string(),
);
assert_vector_eq(
insertion_args_state.arguments.clone(),
&vec![
build_argument("the", "new desc".to_string(), "default value".to_string()),
build_argument("five", None, None),
build_argument("six", None, None),
build_argument(
"four",
"another desc".to_string(),
"change default value".to_string(),
),
],
);
// Insertion that modifies argument name does not retain data
let insertion_into_existing_args_state = ArgumentsState::for_command_workflow(
&insertion_args_state,
"one two {{the}}{{five}} {{forever}}ix}} {{four}}".to_string(),
);
assert_vector_eq(
insertion_into_existing_args_state.arguments.clone(),
&vec![
build_argument("the", "new desc".to_string(), "default value".to_string()),
build_argument("five", None, None),
build_argument("forever", None, None),
build_argument(
"four",
"another desc".to_string(),
"change default value".to_string(),
),
],
);
// Deletion, args before and after retain data
let deletion_args_state = ArgumentsState::for_command_workflow(
&insertion_into_existing_args_state,
"one two {{the}} {{forever}}ix}} {{four}}".to_string(),
);
assert_vector_eq(
deletion_args_state.arguments.clone(),
&vec![
build_argument("the", "new desc".to_string(), "default value".to_string()),
build_argument("forever", None, None),
build_argument(
"four",
"another desc".to_string(),
"change default value".to_string(),
),
],
);
// Deletion that modifies argument name does not retain data
let deletion_into_existing_args_state = ArgumentsState::for_command_workflow(
&deletion_args_state,
"one two {{the}} {{forev}} {{four}}".to_string(),
);
assert_vector_eq(
deletion_into_existing_args_state.arguments.clone(),
&vec![
build_argument("the", "new desc".to_string(), "default value".to_string()),
build_argument("forev", None, None),
build_argument(
"four",
"another desc".to_string(),
"change default value".to_string(),
),
],
);
// Duplicate arguments are not registered separately, only recognize first occurrence index
let repeated_args_state = ArgumentsState::for_command_workflow(
&deletion_into_existing_args_state,
"one two {{the}} {{forev}} {{the}} {{four}}".to_string(),
);
assert_vector_eq(
repeated_args_state.arguments,
&vec![
build_argument("the", "new desc".to_string(), "default value".to_string()),
build_argument("forev", None, None),
build_argument(
"four",
"another desc".to_string(),
"change default value".to_string(),
),
],
);
});
}
#[test]
fn test_arguments_state_from_string_multicursor() {
App::test((), |_app| async move {
let empty_args_state: ArgumentsState = Default::default();
let mut args_state = ArgumentsState::for_command_workflow(
&empty_args_state,
"one two{{three}} {{four}}".to_string(),
);
assert_vector_eq(
args_state.arguments.clone(),
&vec![
build_argument("three", None, None),
build_argument("four", None, None),
],
);
// Mutate data in current arguments state object
if let Some(change_index) = args_state.word_index_to_arg_index_map.get(&2) {
if let Some(change_arg) = args_state.arguments.get_mut(*change_index) {
change_arg.description = Some("new desc".to_string());
change_arg.default_value = Some("default value".to_string());
}
}
if let Some(change_index) = args_state.word_index_to_arg_index_map.get(&3) {
if let Some(change_arg) = args_state.arguments.get_mut(*change_index) {
change_arg.description = Some("another desc".to_string());
change_arg.default_value = Some("change default value".to_string());
}
}
// "on|e two{{thre|e}} {{f|our}}"
// Edit retains data
let multicursor_edit_args_state = ArgumentsState::for_command_workflow(
&args_state,
"onye two{{threye}} {{fyour}}".to_string(),
);
assert_vector_eq(
multicursor_edit_args_state.arguments.clone(),
&vec![
build_argument(
"threye",
"new desc".to_string(),
"default value".to_string(),
),
build_argument(
"fyour",
"another desc".to_string(),
"change default value".to_string(),
),
],
);
// "on|ye two{{th|reye}} {{fyour}}|"
// Insert retains data for matching argument names
let mut multicursor_insert_args_state = ArgumentsState::for_command_workflow(
&multicursor_edit_args_state,
"onyee two{{thereye}} {{fyour}}e".to_string(),
);
assert_vector_eq(
multicursor_insert_args_state.arguments.clone(),
&vec![
build_argument("thereye", None, None),
build_argument(
"fyour",
"another desc".to_string(),
"change default value".to_string(),
),
],
);
if let Some(change_index) = multicursor_insert_args_state
.word_index_to_arg_index_map
.get(&2)
{
if let Some(change_arg) = multicursor_insert_args_state
.arguments
.get_mut(*change_index)
{
change_arg.description = Some("test desc".to_string());
change_arg.default_value = Some("with dvalue".to_string());
}
}
// "on|yee |two{{thereye}} {{fyou|r}}e"
// Delete retains data for matching argument names
let multicursor_delete_args_state = ArgumentsState::for_command_workflow(
&multicursor_insert_args_state,
"oyeetwo{{thereye}} {{fyor}}e".to_string(),
);
assert_vector_eq(
multicursor_delete_args_state.arguments,
&vec![
build_argument(
"thereye",
"test desc".to_string(),
"with dvalue".to_string(),
),
build_argument("fyor", None, None),
],
);
});
}
#[test]
fn test_arguments_state_from_string_with_leading_whitespace() {
App::test((), |_app| async move {
let empty_args_state: ArgumentsState = Default::default();
let args_state = ArgumentsState::for_command_workflow(
&empty_args_state,
" one two{{three}} {{four}}".to_string(),
);
assert_vector_eq(
args_state.arguments,
&vec![
build_argument("three", None, None),
build_argument("four", None, None),
],
);
});
}
@@ -0,0 +1,977 @@
use std::rc::Rc;
use strum::IntoEnumIterator;
use strum_macros::{EnumIter, IntoStaticStr};
use warp_core::{features::FeatureFlag, ui::appearance::Appearance};
use warp_editor::editor::NavigationKey;
use warpui::{
elements::{
Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Empty, Fill, Flex, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Shrinkable,
},
ui_components::{
button::ButtonVariant,
components::{UiComponent, UiComponentStyles},
toggle_menu::{ToggleMenuItem, ToggleMenuStateHandle},
},
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::{
cloud_object::{model::persistence::CloudModel, Revision},
editor::{
EditorOptions, EditorView, Event, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
},
server::ids::{ClientId, SyncId},
ui_components::{buttons::icon_button, icons::Icon},
workflows::workflow_enum::EnumVariants,
};
const CONTAINER_PADDING: f32 = 16.;
const CORE_WIDTH: f32 = 400.;
const CORE_HEIGHT: f32 = 250.;
const ELEMENT_SPACING: f32 = 10.;
const OFFSET_FOR_SCROLLBAR: f32 = 12.;
const ROW_MARGIN: f32 = 8.;
const ROW_SPACING: f32 = 4.;
const SECTION_SPACING: f32 = 24.;
const VARIANT_EDITOR_HEIGHT: f32 = 40.;
const COMMAND_EDITOR_HEIGHT: f32 = 120.;
const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto;
const BUTTON_FONT_SIZE: f32 = 14.;
const EDITOR_FONT_SIZE: f32 = 14.;
const SECTION_FONT_SIZE: f32 = 16.;
const SPAN_FONT_SIZE: f32 = 16.;
const VARIANT_FONT_SIZE: f32 = 13.;
const CANCEL_BUTTON_LABEL: &str = "Close";
const NEW_ENUM_SPAN: &str = "New enum";
const EXISTING_ENUM_SPAN: &str = "Edit enum";
const NAME_PLACEHOLDER_TEXT: &str = "Name";
const CREATE_BUTTON_LABEL: &str = "Create";
const SAVE_BUTTON_LABEL: &str = "Save";
const VARIANT_PLACEHOLDER_TEXT: &str = "Variant";
const STATIC_LABEL_TEXT: &str = "Variants";
const DYNAMIC_PLACEHOLDER_TEXT: &str =
"# Enter a shell command that generates variants, delimited by newlines.\n\ngit branch -a";
#[derive(Debug, Clone)]
pub enum EnumCreationDialogAction {
Close,
SaveEnum,
AddVariant,
DeleteVariant(VariantRowIndex),
}
#[derive(Debug, Clone)]
pub enum EnumCreationDialogEvent {
Close,
/// Create a new enum, with the `WorkflowEnumData` included
CreateEnum(WorkflowEnumData),
/// Edit the enum with this ID in the list of enums stored, with the `WorkflowEnumData`
/// The boolean value represents if the visibility of the enum changed (went from unshared to shared
/// or vice versa), which is used when updating the selector states.
EditEnum(WorkflowEnumData, bool),
}
/// Struct for holding workflow enum data associated with this argument
#[derive(Debug, Clone, PartialEq)]
pub struct WorkflowEnumData {
/// Every enum argument will have an id and a name
pub id: SyncId,
pub name: String,
/// If the enum is shared or not, used when determining if an enum should be displayed in the dropdown
pub is_shared: bool,
/// The revision_ts of the enum, None if it has not yet been created.
pub revision_ts: Option<Revision>,
/// This field contains any new enum data that has not been saved,
/// i.e. created enums or updated enums.
pub new_data: Option<EnumVariants>,
}
#[derive(Debug, Clone)]
pub struct VariantRowIndex(usize);
pub struct VariantEditorRow {
variant_editor: ViewHandle<EditorView>,
delete_row_mouse_state_handle: MouseStateHandle,
}
#[derive(Default)]
struct MouseStateHandles {
cancel_button_mouse_state_handle: MouseStateHandle,
save_button_mouse_state_handle: MouseStateHandle,
add_variant_state: MouseStateHandle,
}
pub struct EnumCreationDialog {
variants_clipped_scroll_state: ClippedScrollStateHandle,
mouse_state_handles: MouseStateHandles,
name_editor: ViewHandle<EditorView>,
variant_rows: Vec<VariantEditorRow>,
/// The `sync_id` of the enum in the dialog if it already exists,
/// `None` if this is a new enum.
sync_id: Option<SyncId>,
/// The revision timestamp of the enum, if it has been loaded in from the server.
revision_ts: Option<Revision>,
/// Store the base state of the enum dialog, used for determining if the dialog is dirty
base_dialog_state: BaseEnumDialogState,
// The handles and options used for the type toggle menu
enum_type_handles: EnumTypeHandles,
enum_type_options: Vec<EnumType>,
dynamic_command_editor: ViewHandle<EditorView>,
}
#[derive(Debug, Default, PartialEq)]
struct BaseEnumDialogState {
/// Store the number of rows to determine if a variant was removed
variant_rows: usize,
is_enum_shared: bool,
selected_type: EnumType,
}
#[derive(Default, Clone)]
struct EnumTypeHandles {
enum_type_state_handle: ToggleMenuStateHandle,
enum_type_mouse_states: Vec<MouseStateHandle>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, IntoStaticStr, EnumIter)]
enum EnumType {
#[default]
Static,
Dynamic,
}
impl EnumCreationDialog {
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 enum_type_handles = EnumTypeHandles {
// We need one mouse state for each enum type.
enum_type_mouse_states: vec![Default::default(), Default::default()],
..Default::default()
};
let enum_type_options = EnumType::iter().collect();
let dynamic_command_editor = {
ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let text = TextOptions {
font_size_override: Some(EDITOR_FONT_SIZE),
font_family_override: Some(appearance.monospace_font_family()),
..Default::default()
};
let options = EditorOptions {
text,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
soft_wrap: true,
placeholder_soft_wrap: true,
..Default::default()
};
let mut editor = EditorView::new(options, ctx);
editor.set_placeholder_text(DYNAMIC_PLACEHOLDER_TEXT, ctx);
editor.set_autogrow(true);
editor
})
};
ctx.subscribe_to_view(&dynamic_command_editor, |me, _, event, ctx| {
me.handle_command_editor_event(event, ctx);
});
Self {
mouse_state_handles: Default::default(),
variants_clipped_scroll_state: Default::default(),
name_editor,
variant_rows: Vec::new(),
sync_id: None,
base_dialog_state: Default::default(),
revision_ts: None,
enum_type_handles,
enum_type_options,
dynamic_command_editor,
}
}
// This function gets called when we are creating an enum from scratch
pub fn initialize(&mut self, ctx: &mut ViewContext<Self>) {
self.add_variant_row(ctx);
self.base_dialog_state = BaseEnumDialogState {
variant_rows: 1,
is_enum_shared: false,
selected_type: EnumType::Static,
}
}
// Internal function used to load an enum into the editor
fn load_enum(
&mut self,
name: &str,
enum_id: SyncId,
is_shared: bool,
variants: &EnumVariants,
ctx: &mut ViewContext<Self>,
) {
// Set the stored id to be the id of the existing enum
self.sync_id = Some(enum_id);
// Populate the dialog with the enum name and variants
self.name_editor.update(ctx, |buffer, ctx| {
buffer.set_buffer_text_with_base_buffer(name, ctx)
});
let base_selected_type = match variants {
EnumVariants::Static(variants) => {
self.set_selected_type(EnumType::Static, ctx);
variants.iter().for_each(|variant| {
self.add_variant_row(ctx);
self.variant_rows[self.variant_rows.len() - 1]
.variant_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text_with_base_buffer(variant.as_str(), ctx);
})
});
EnumType::Static
}
EnumVariants::Dynamic(command) => {
// We add a static variant row to set up the default state for
// if the user switches to a static enum.
self.add_variant_row(ctx);
self.set_selected_type(EnumType::Dynamic, ctx);
self.dynamic_command_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text_with_base_buffer(command.as_str(), ctx);
});
EnumType::Dynamic
}
};
self.base_dialog_state = BaseEnumDialogState {
variant_rows: self.variant_rows.len(),
is_enum_shared: is_shared,
selected_type: base_selected_type,
};
}
// Load an enum given its variants
pub fn load_from_data(
&mut self,
name: &str,
enum_id: SyncId,
is_shared: bool,
enum_data: &EnumVariants,
ctx: &mut ViewContext<Self>,
) {
self.load_enum(name, enum_id, is_shared, enum_data, ctx);
}
// Load an enum from memory
pub fn load_from_cloud_model(&mut self, enum_id: SyncId, ctx: &mut ViewContext<Self>) {
let cloud_model = CloudModel::as_ref(ctx);
let workflow_enum_model = cloud_model.get_workflow_enum(&enum_id);
self.revision_ts = workflow_enum_model.and_then(|model| model.metadata.revision.clone());
let workflow_enum =
workflow_enum_model.map(|workflow_enum| workflow_enum.model().string_model.clone());
if let Some(workflow_enum) = workflow_enum {
self.load_enum(
&workflow_enum.name,
enum_id,
workflow_enum.is_shared,
&workflow_enum.variants,
ctx,
);
} else {
// If we couldn't find an enum with this SyncId, open an empty dialog
self.initialize(ctx);
}
}
fn get_selected_type(&self) -> EnumType {
let selected_idx = self
.enum_type_handles
.enum_type_state_handle
.get_selected_idx()
.unwrap_or(0);
self.enum_type_options[selected_idx]
}
fn set_selected_type(&mut self, selected_type: EnumType, ctx: &mut ViewContext<Self>) {
self.enum_type_handles
.enum_type_state_handle
.set_selected_idx(self.get_enum_type_idx(selected_type));
ctx.notify();
}
fn get_enum_type_idx(&self, arg_type: EnumType) -> usize {
self.enum_type_options
.iter()
.position(|type_option| *type_option == arg_type)
.unwrap_or(0)
}
fn handle_name_editor_event(&mut self, event: &Event, ctx: &mut ViewContext<Self>) {
match event {
Event::Navigate(NavigationKey::Tab) => match self.get_selected_type() {
EnumType::Static => {
if let Some(variant_row) = self.variant_rows.first() {
ctx.focus(&variant_row.variant_editor);
}
}
EnumType::Dynamic => ctx.focus(&self.dynamic_command_editor),
},
Event::Navigate(NavigationKey::ShiftTab) => match self.get_selected_type() {
EnumType::Static => {
if let Some(variant_row) = self.variant_rows.last() {
ctx.focus(&variant_row.variant_editor);
}
}
EnumType::Dynamic => ctx.focus(&self.dynamic_command_editor),
},
Event::Edited(_) => {
ctx.notify();
}
_ => {}
}
}
fn handle_command_editor_event(&mut self, event: &Event, ctx: &mut ViewContext<Self>) {
match event {
Event::Navigate(NavigationKey::Tab) => ctx.focus(&self.name_editor),
Event::Navigate(NavigationKey::ShiftTab) => ctx.focus(&self.name_editor),
Event::Navigate(NavigationKey::Up) => self
.dynamic_command_editor
.update(ctx, |input, ctx| input.move_up(ctx)),
Event::Navigate(NavigationKey::Down) => self
.dynamic_command_editor
.update(ctx, |input, ctx| input.move_down(ctx)),
Event::Edited(_) => {
ctx.notify();
}
_ => {}
}
}
// Determine if the enum dialog is dirty
fn is_dirty(&self, app: &AppContext) -> bool {
let selected_type = self.get_selected_type();
let variants_are_dirty = match selected_type {
EnumType::Static => {
let any_variant_is_dirty = self
.variant_rows
.iter()
.any(|row| row.variant_editor.as_ref(app).is_dirty(app));
any_variant_is_dirty
|| self.base_dialog_state.variant_rows != self.variant_rows.len()
}
EnumType::Dynamic => self.dynamic_command_editor.as_ref(app).is_dirty(app),
};
let name_is_dirty = self.name_editor.as_ref(app).is_dirty(app);
let selected_type_is_dirty = self.base_dialog_state.selected_type != selected_type;
variants_are_dirty || name_is_dirty || selected_type_is_dirty
}
fn handle_variant_event(
&mut self,
handle: ViewHandle<EditorView>,
event: &Event,
ctx: &mut ViewContext<Self>,
) {
// get the index of the row where the event originated
let index = self
.variant_rows
.iter()
.enumerate()
.find_map(|(index, editor)| {
if editor.variant_editor == handle {
Some(index)
} else {
None
}
});
match event {
Event::Navigate(NavigationKey::ShiftTab) => {
self.focus_prev_variant_editor(index, ctx);
}
Event::Navigate(NavigationKey::Tab) => {
self.focus_next_variant_editor(index, ctx);
}
Event::Edited(_) => {
ctx.notify();
}
_ => {}
}
}
fn focus_prev_variant_editor(&self, index: Option<usize>, ctx: &mut ViewContext<Self>) {
let Some(index) = index else { return };
if index == 0 {
ctx.focus(&self.name_editor);
} else if let Some(prev_variant_row) = self.variant_rows.get(index - 1) {
ctx.focus(&prev_variant_row.variant_editor);
}
}
fn focus_next_variant_editor(&mut self, index: Option<usize>, ctx: &mut ViewContext<Self>) {
let Some(index) = index else { return };
if index == self.variant_rows.len() - 1 {
self.add_variant_row(ctx);
}
if let Some(next_variant_row) = self.variant_rows.get(index + 1) {
ctx.focus(&next_variant_row.variant_editor);
}
}
fn should_disable_save(&self, app: &AppContext) -> bool {
let variants_empty = match self.get_selected_type() {
EnumType::Static => self.editors_are_empty(app) || self.variant_rows.is_empty(),
EnumType::Dynamic => self.dynamic_command_editor.as_ref(app).is_empty(app),
};
let name_empty = self.name_editor.as_ref(app).is_empty(app);
variants_empty || name_empty || !self.is_dirty(app)
}
fn editors_are_empty(&self, app: &AppContext) -> bool {
self.variant_rows
.iter()
.any(|row| row.variant_editor.as_ref(app).is_empty(app))
}
fn save_enum_and_close(&mut self, ctx: &mut ViewContext<Self>) {
if self.should_disable_save(ctx) {
return;
}
let variants = match self.get_selected_type() {
EnumType::Static => EnumVariants::Static(
self.variant_rows
.iter()
.map(|variant_row| variant_row.variant_editor.as_ref(ctx).buffer_text(ctx))
.collect(),
),
EnumType::Dynamic => {
EnumVariants::Dynamic(self.dynamic_command_editor.as_ref(ctx).buffer_text(ctx))
}
};
match self.sync_id {
// If an existing index was passed into the enum dialog and the view is dirty, we have edited the enum
Some(id) => {
if self.is_dirty(ctx) {
ctx.emit(EnumCreationDialogEvent::EditEnum(
WorkflowEnumData {
id,
name: self.name_editor.as_ref(ctx).buffer_text(ctx),
is_shared: true,
revision_ts: self.revision_ts.clone(),
new_data: Some(variants),
},
false,
));
}
}
// If we don't have an existing index, we are creating a new enum
None => {
ctx.emit(EnumCreationDialogEvent::CreateEnum(WorkflowEnumData {
id: SyncId::ClientId(ClientId::default()),
name: self.name_editor.as_ref(ctx).buffer_text(ctx),
is_shared: true,
revision_ts: self.revision_ts.clone(),
new_data: Some(variants),
}));
}
}
self.close(ctx);
}
fn close(&mut self, ctx: &mut ViewContext<Self>) {
// Clear the enum editor fields
self.name_editor
.update(ctx, |buffer, ctx| buffer.clear_buffer(ctx));
self.dynamic_command_editor
.update(ctx, |buffer, ctx| buffer.clear_buffer(ctx));
self.variant_rows = Vec::new();
self.sync_id = None;
self.revision_ts = None;
self.set_selected_type(EnumType::default(), ctx);
ctx.emit(EnumCreationDialogEvent::Close)
}
fn add_variant_row(&mut self, ctx: &mut ViewContext<Self>) {
let appearance = Appearance::as_ref(ctx);
let ui_font_family = appearance.ui_font_family();
let variant_editor = ctx.add_typed_action_view(|ctx| {
let mut editor = EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions {
font_size_override: Some(VARIANT_FONT_SIZE),
font_family_override: Some(ui_font_family),
..Default::default()
},
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
},
ctx,
);
editor.set_placeholder_text(VARIANT_PLACEHOLDER_TEXT, ctx);
editor
});
ctx.subscribe_to_view(&variant_editor, |me, emitter, event, ctx| {
me.handle_variant_event(emitter, event, ctx);
});
self.variant_rows.push(VariantEditorRow {
variant_editor,
delete_row_mouse_state_handle: Default::default(),
});
ctx.notify();
}
fn delete_row(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
self.variant_rows.remove(index);
if self.variant_rows.is_empty() {
self.add_variant_row(ctx);
}
ctx.notify();
}
fn render_button(
&self,
appearance: &Appearance,
button_mouse_state: MouseStateHandle,
action: EnumCreationDialogAction,
label_text: &str,
is_save: bool,
is_disabled: bool,
) -> 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_disabled {
button = button.disabled();
}
button
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
.with_cursor(warpui::platform::Cursor::PointingHand)
.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_horizontal_margin(CONTAINER_PADDING)
.with_margin_bottom(SECTION_SPACING)
.finish()
}
fn render_dialog_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let text = match self.sync_id {
Some(_) => EXISTING_ENUM_SPAN,
None => NEW_ENUM_SPAN,
};
appearance
.ui_builder()
.span(text)
.with_style(UiComponentStyles {
font_size: Some(SPAN_FONT_SIZE),
..Default::default()
})
.build()
.finish()
}
fn render_toggle_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
if FeatureFlag::DynamicWorkflowEnums.is_enabled() {
Container::new(
appearance
.ui_builder()
.toggle_menu(
self.enum_type_handles.enum_type_mouse_states.clone(),
self.enum_type_options
.iter()
.map(|arg_type| {
let label: &'static str = arg_type.into();
ToggleMenuItem::new(label)
})
.collect(),
self.enum_type_handles.enum_type_state_handle.clone(),
Some(0),
Some(appearance.theme().background()),
Some(appearance.theme().surface_2()),
Some(appearance.theme().surface_3()),
appearance.ui_font_size(),
Rc::new(|_, _, _| {}),
)
.build()
.finish(),
)
.with_horizontal_margin(CONTAINER_PADDING)
.with_margin_bottom(ROW_MARGIN)
.finish()
} else {
Empty::new().finish()
}
}
fn render_variants_section(&self, appearance: &Appearance) -> Box<dyn Element> {
match self.get_selected_type() {
EnumType::Static => self.render_static_section(appearance),
EnumType::Dynamic => {
if FeatureFlag::DynamicWorkflowEnums.is_enabled() {
self.render_dynamic_section(appearance)
} else {
self.render_static_section(appearance)
}
}
}
}
fn render_static_section(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
Flex::column()
.with_child(
Container::new(self.render_static_section_header(appearance))
.with_horizontal_margin(CONTAINER_PADDING)
.with_margin_bottom(ROW_MARGIN)
.finish(),
)
.with_child(
Container::new(
ConstrainedBox::new(
ClippedScrollable::vertical(
self.variants_clipped_scroll_state.clone(),
Container::new(
Flex::column()
.with_children(self.render_variant_rows(appearance))
.finish(),
)
.finish(),
SCROLLBAR_WIDTH,
theme.disabled_text_color(theme.background()).into(),
theme.main_text_color(theme.background()).into(),
Fill::None,
)
.finish(),
)
.with_max_height(200.)
.finish(),
)
.with_margin_left(CONTAINER_PADDING)
.with_margin_right(CONTAINER_PADDING - OFFSET_FOR_SCROLLBAR)
.with_margin_bottom(SECTION_SPACING)
.finish(),
)
.finish()
}
fn render_dynamic_section(&self, appearance: &Appearance) -> Box<dyn Element> {
let command_editor = ConstrainedBox::new(
appearance
.ui_builder()
.text_input(self.dynamic_command_editor.clone())
.with_style(UiComponentStyles::default())
.build()
.finish(),
)
.with_min_height(COMMAND_EDITOR_HEIGHT)
.finish();
Container::new(command_editor)
.with_horizontal_margin(CONTAINER_PADDING)
.with_margin_bottom(SECTION_SPACING)
.finish()
}
fn render_variant_editor(
&self,
appearance: &Appearance,
editor: ViewHandle<EditorView>,
) -> Box<dyn Element> {
Shrinkable::new(
1.,
Container::new(
ConstrainedBox::new(
appearance
.ui_builder()
.text_input(editor.clone())
.with_style(UiComponentStyles::default())
.build()
.finish(),
)
.with_max_height(VARIANT_EDITOR_HEIGHT)
.finish(),
)
.with_margin_right(ROW_SPACING)
.finish(),
)
.finish()
}
fn render_variant_rows(&self, appearance: &Appearance) -> Vec<Box<dyn Element>> {
let variants: Vec<Box<dyn Element>> = self
.variant_rows
.iter()
.enumerate()
.map(|(index, variant_editor_row)| {
Container::new(
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(self.render_variant_editor(
appearance,
variant_editor_row.variant_editor.clone(),
))
.with_child(
icon_button(
appearance,
Icon::MinusCircle,
false,
variant_editor_row.delete_row_mouse_state_handle.clone(),
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EnumCreationDialogAction::DeleteVariant(
VariantRowIndex(index),
))
})
.finish(),
)
.finish(),
)
.with_margin_bottom(ROW_MARGIN)
.finish()
})
.collect();
variants
}
fn render_static_section_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut variants_header = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
variants_header.add_child(
Shrinkable::new(
1.,
appearance
.ui_builder()
.span(STATIC_LABEL_TEXT.to_string())
.with_style(UiComponentStyles {
font_size: Some(SECTION_FONT_SIZE),
..Default::default()
})
.build()
.finish(),
)
.finish(),
);
variants_header.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.mouse_state_handles.add_variant_state.clone(),
)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(EnumCreationDialogAction::AddVariant)
})
.finish(),
)
.finish(),
)
.finish(),
);
variants_header.finish()
}
fn render_footer_buttons(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let disable_save = self.should_disable_save(app);
let save_button_label = match self.sync_id {
None => CREATE_BUTTON_LABEL,
Some(_) => SAVE_BUTTON_LABEL,
};
Flex::row()
.with_child(
Shrinkable::new(
1.,
Container::new(
self.render_button(
appearance,
self.mouse_state_handles
.cancel_button_mouse_state_handle
.clone(),
EnumCreationDialogAction::Close,
CANCEL_BUTTON_LABEL,
false,
false,
),
)
.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(),
EnumCreationDialogAction::SaveEnum,
save_button_label,
true,
disable_save,
),
)
.finish(),
)
.finish()
}
}
impl Entity for EnumCreationDialog {
type Event = EnumCreationDialogEvent;
}
impl View for EnumCreationDialog {
fn ui_name() -> &'static str {
"EnumCreationDialog"
}
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(
Container::new(self.render_dialog_header(appearance))
.with_horizontal_margin(CONTAINER_PADDING)
.with_vertical_margin(SECTION_SPACING)
.finish(),
)
.with_child(self.render_name_editor(appearance))
.with_child(Container::new(self.render_toggle_buttons(appearance)).finish())
.with_child(self.render_variants_section(appearance))
.with_child(
Container::new(self.render_footer_buttons(appearance, app))
.with_horizontal_margin(CONTAINER_PADDING)
.with_margin_bottom(CONTAINER_PADDING)
.finish(),
)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.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 EnumCreationDialog {
type Action = EnumCreationDialogAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
EnumCreationDialogAction::Close => self.close(ctx),
EnumCreationDialogAction::SaveEnum => self.save_enum_and_close(ctx),
EnumCreationDialogAction::AddVariant => self.add_variant_row(ctx),
EnumCreationDialogAction::DeleteVariant(VariantRowIndex(index)) => {
self.delete_row(*index, ctx);
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod ai_assist;
pub mod arguments;
pub mod enum_creation_dialog;
pub mod modal;
pub mod workflow_arg_selector;
pub mod workflow_arg_type_helpers;
File diff suppressed because it is too large Load Diff
+609
View File
@@ -0,0 +1,609 @@
use warp_core::ui::appearance::Appearance;
use warpui::{platform::WindowStyle, App, SingletonEntity, ViewHandle};
use std::sync::Arc;
use super::WorkflowModal;
use crate::auth::AuthStateProvider;
use crate::{
cloud_object::model::persistence::CloudModel,
editor::PlainTextEditorViewAction as EditorAction,
server::server_api::team::MockTeamClient,
server::server_api::workspace::MockWorkspaceClient,
server::server_api::ServerApiProvider,
settings_view::keybindings::KeybindingChangedNotifier,
test_util::settings::initialize_settings_for_tests,
workflows::workflow::{Argument, Workflow},
UserWorkspaces,
};
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
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);
let team_client_mock = Arc::new(MockTeamClient::new());
let workspace_client_mock = Arc::new(MockWorkspaceClient::new());
app.add_singleton_model(|ctx| {
UserWorkspaces::mock(
team_client_mock.clone(),
workspace_client_mock.clone(),
vec![],
ctx,
)
});
}
fn create_modal(app: &mut App) -> ViewHandle<WorkflowModal> {
initialize_app(app);
let (_, modal_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
let server_api = ServerApiProvider::as_ref(ctx).get();
WorkflowModal::new(server_api.clone(), ctx)
});
modal_view
}
fn build_argument(
name: impl Into<String>,
description: impl Into<Option<String>>,
default_value: impl Into<Option<String>>,
) -> Argument {
Argument {
name: name.into(),
description: description.into(),
default_value: default_value.into(),
arg_type: Default::default(),
}
}
#[test]
fn test_pasting_command_no_argument_overlap_fewer_arguments() {
App::test((), |mut app| async move {
let modal_view = create_modal(&mut app);
modal_view.update(&mut app, |view, ctx| {
view.content_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("{{foo_1}} {{foo_2}}", ctx);
});
});
modal_view.read(&app, |view, _| {
assert_eq!(view.arguments_rows.len(), 2);
});
modal_view.update(&mut app, |view, ctx| {
view.arguments_rows[0]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_1", ctx);
});
view.arguments_rows[0]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_1", ctx);
});
view.arguments_rows[1]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_2", ctx);
});
view.arguments_rows[1]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_2", ctx);
});
view.content_editor.update(ctx, |command_editor, ctx| {
command_editor.select_all(ctx);
command_editor.user_initiated_insert("{{bar_1}}", EditorAction::Paste, ctx);
});
});
modal_view.read(&app, |view, app| {
assert_eq!(view.arguments_rows.len(), 1);
assert!(view.arguments_rows[0]
.description_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert!(view.arguments_rows[0]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
});
});
}
#[test]
fn test_pasting_command_no_argument_overlap_more_arguments() {
App::test((), |mut app| async move {
let modal_view = create_modal(&mut app);
modal_view.update(&mut app, |view, ctx| {
view.content_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("{{foo_1}}", ctx);
});
});
modal_view.read(&app, |view, _| {
assert_eq!(view.arguments_rows.len(), 1);
});
modal_view.update(&mut app, |view, ctx| {
view.arguments_rows[0]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_1", ctx);
});
view.arguments_rows[0]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_1", ctx);
});
view.content_editor.update(ctx, |command_editor, ctx| {
command_editor.select_all(ctx);
command_editor.user_initiated_insert(
"{{bar_1}} {{bar_2}}",
EditorAction::Paste,
ctx,
);
});
});
modal_view.read(&app, |view, app| {
assert_eq!(view.arguments_rows.len(), 2);
assert!(view.arguments_rows[0]
.description_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert!(view.arguments_rows[0]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert!(view.arguments_rows[1]
.description_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert!(view.arguments_rows[1]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
});
});
}
#[test]
fn test_pasting_command_some_argument_overlap_fewer_arguments() {
App::test((), |mut app| async move {
let modal_view = create_modal(&mut app);
modal_view.update(&mut app, |view, ctx| {
view.content_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("{{foo_1}} {{foo_2}} {{foo_3}}", ctx);
});
});
modal_view.read(&app, |view, _| {
assert_eq!(view.arguments_rows.len(), 3);
});
modal_view.update(&mut app, |view, ctx| {
view.arguments_rows[0]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_1", ctx);
});
view.arguments_rows[0]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_1", ctx);
});
view.arguments_rows[1]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_2", ctx);
});
view.arguments_rows[1]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_2", ctx);
});
view.arguments_rows[2]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_3", ctx);
});
view.arguments_rows[2]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_3", ctx);
});
view.content_editor.update(ctx, |command_editor, ctx| {
command_editor.select_all(ctx);
command_editor.user_initiated_insert(
"{{foo_3}} {{bar_1}}",
EditorAction::Paste,
ctx,
);
});
});
modal_view.read(&app, |view, app| {
assert_eq!(view.arguments_rows.len(), 2);
assert_eq!(
view.arguments_rows[0]
.description_editor
.as_ref(app)
.buffer_text(app)
.as_str(),
"description for foo_3"
);
assert_eq!(
view.arguments_rows[0]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.as_str(),
"default value for foo_3"
);
assert!(view.arguments_rows[1]
.description_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert!(view.arguments_rows[1]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
});
});
}
#[test]
fn test_pasting_command_some_argument_overlap_more_arguments() {
App::test((), |mut app| async move {
let modal_view = create_modal(&mut app);
modal_view.update(&mut app, |view, ctx| {
view.content_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("{{foo_1}} {{foo_2}}", ctx);
});
});
modal_view.read(&app, |view, _| {
assert_eq!(view.arguments_rows.len(), 2);
});
modal_view.update(&mut app, |view, ctx| {
view.arguments_rows[0]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_1", ctx);
});
view.arguments_rows[0]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_1", ctx);
});
view.arguments_rows[1]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_2", ctx);
});
view.arguments_rows[1]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_2", ctx);
});
view.content_editor.update(ctx, |command_editor, ctx| {
command_editor.select_all(ctx);
command_editor.user_initiated_insert(
"{{bar_1}} {{bar_2}} {{foo_2}} {{bar_3}}",
EditorAction::Paste,
ctx,
);
});
});
modal_view.read(&app, |view, app| {
assert_eq!(view.arguments_rows.len(), 4);
assert!(view.arguments_rows[0]
.description_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert!(view.arguments_rows[0]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert!(view.arguments_rows[1]
.description_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert!(view.arguments_rows[1]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert_eq!(
view.arguments_rows[2]
.description_editor
.as_ref(app)
.buffer_text(app)
.as_str(),
"description for foo_2"
);
assert_eq!(
view.arguments_rows[2]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.as_str(),
"default value for foo_2"
);
assert!(view.arguments_rows[3]
.description_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
assert!(view.arguments_rows[3]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.is_empty());
});
});
}
#[test]
fn test_pasting_command_same_number_of_arguments() {
App::test((), |mut app| async move {
let modal_view = create_modal(&mut app);
modal_view.update(&mut app, |view, ctx| {
view.content_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("{{foo_1}} {{foo_2}}", ctx);
});
});
modal_view.read(&app, |view, _| {
assert_eq!(view.arguments_rows.len(), 2);
});
modal_view.update(&mut app, |view, ctx| {
view.arguments_rows[0]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_1", ctx);
});
view.arguments_rows[0]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_1", ctx);
});
view.arguments_rows[1]
.description_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("description for foo_2", ctx);
});
view.arguments_rows[1]
.default_value_editor
.update(ctx, |editor, ctx| {
editor.set_buffer_text("default value for foo_2", ctx);
});
view.content_editor.update(ctx, |command_editor, ctx| {
command_editor.select_all(ctx);
command_editor.user_initiated_insert(
"{{bar_1}} {{bar_2}}",
EditorAction::Paste,
ctx,
);
});
});
modal_view.read(&app, |view, app| {
assert_eq!(view.arguments_rows.len(), 2);
// if we have the same # of args before/after, it's a coin toss as to whether the args
// are semantically the same or not. err on the side of not blowing away the associated
// descriptions / default values.
assert_eq!(
view.arguments_rows[0]
.description_editor
.as_ref(app)
.buffer_text(app)
.as_str(),
"description for foo_1"
);
assert_eq!(
view.arguments_rows[0]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.as_str(),
"default value for foo_1"
);
assert_eq!(
view.arguments_rows[1]
.description_editor
.as_ref(app)
.buffer_text(app)
.as_str(),
"description for foo_2"
);
assert_eq!(
view.arguments_rows[1]
.default_value_editor
.as_ref(app)
.buffer_text(app)
.as_str(),
"default value for foo_2"
);
});
});
}
#[test]
fn test_populating_missing_fields_with_suggestion() {
App::test((), |mut app| async move {
let modal_view = create_modal(&mut app);
modal_view.update(&mut app, |view, ctx| {
view.content_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("git {{foo_1}} {{foo_2}}", ctx);
});
view.title_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("Title foo", ctx);
});
});
modal_view.read(&app, |view, _| {
assert_eq!(view.arguments_rows.len(), 2);
});
modal_view.update(&mut app, |view, ctx| {
let workflow = Workflow::Command {
name: "New Title".to_string(),
description: Some("New description".to_string()),
command: "git foo_1 foo_2".to_string(),
arguments: vec![],
tags: vec![],
source_url: None,
author: None,
author_url: None,
shells: vec![],
environment_variables: None,
};
view.populate_missing_field_with_suggestion(workflow, ctx)
});
modal_view.read(&app, |view, app| {
assert_eq!(view.arguments_rows.len(), 2);
assert_eq!(
view.content_editor.as_ref(app).buffer_text(app).as_str(),
"git {{foo_1}} {{foo_2}}"
);
assert_eq!(
view.title_editor.as_ref(app).buffer_text(app).as_str(),
"Title foo"
);
assert_eq!(
view.description_editor
.as_ref(app)
.buffer_text(app)
.as_str(),
"New description"
);
});
});
}
#[test]
fn test_populating_with_sanitization() {
App::test((), |mut app| async move {
let modal_view = create_modal(&mut app);
modal_view.update(&mut app, |view, ctx| {
view.content_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(
"tar -czvf {{9output_(file).tar.gz}} {{input_directory}} {{.file9!_zip}}",
ctx,
);
});
view.title_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("Title foo", ctx);
});
});
modal_view.update(&mut app, |view, ctx| {
let workflow = Workflow::Command {
name: "New Title".to_string(),
description: Some("New description".to_string()),
command: "tar -czvf {{9output_(file).tar.gz}} {{input_directory}} {{.file9!_zip}}"
.to_string(),
arguments: vec![
build_argument("9output_(file).tar.gz", None, None),
build_argument("input_directory", None, None),
build_argument(".file9!_zip", None, None),
],
tags: vec![],
source_url: None,
author: None,
author_url: None,
shells: vec![],
environment_variables: None,
};
view.populate(workflow, ctx)
});
modal_view.read(&app, |view, app| {
assert_eq!(view.arguments_rows.len(), 3);
assert_eq!(
view.content_editor.as_ref(app).buffer_text(app).as_str(),
"tar -czvf {{output_file_tar_gz}} {{input_directory}} {{_file9_zip}}"
);
});
});
}
@@ -0,0 +1,960 @@
use itertools::Itertools;
use std::{collections::HashMap, rc::Rc};
use strum::IntoEnumIterator;
use warp_core::ui::{appearance::Appearance, theme::Fill};
use warp_editor::editor::NavigationKey;
use warpui::{
elements::{
Align, Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Empty, EventHandler,
Flex, Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement, Radius,
ScrollbarWidth, Shrinkable, Stack, Text,
},
geometry::vector::vec2f,
ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
text::Span,
toggle_menu::{ToggleMenuItem, ToggleMenuStateHandle},
},
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::{
editor::{
EditorOptions, EditorView, EnterSettings, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, TextOptions,
},
server::ids::SyncId,
ui_components::{
buttons::{highlight, icon_button},
icons::{self, Icon},
},
workflows::workflow::ArgumentType,
};
use warpui::platform::Cursor;
use warpui::{
elements::{ParentAnchor, ParentOffsetBounds},
fonts::FamilyId,
};
use crate::editor::EnterAction;
use strum_macros::{EnumIter, IntoStaticStr};
use super::enum_creation_dialog::WorkflowEnumData;
const ARGUMENT_DEFAULT_VALUE_PLACEHOLDER_TEXT: &str = "Default value (optional)";
const ARGUMENT_EDITOR_FONT_SIZE: f32 = 14.;
const DROPDOWN_PADDING: f32 = 8.;
const DROPDOWN_BORDER_RADIUS: f32 = 6.;
const EDIT_ICON_HEIGHT: f32 = 24.;
const ENUM_MENU_HEIGHT: f32 = 100.;
const MENU_ITEM_VERTICAL_PADDING: f32 = 4.;
const MENU_ITEM_HORIZONTAL_PADDING: f32 = 8.;
const MENU_ITEM_HORIZONTAL_MARGIN: f32 = 12.;
const TOGGLE_MENU_BOTTOM_PADDING: f32 = 4.;
pub struct WorkflowArgSelector {
pub text_editor: ViewHandle<EditorView>,
// `is_expanded` is true when the selector is open
is_expanded: bool,
is_disabled: bool,
editor_mouse_state: MouseStateHandle,
// The handles and options used for the type radio buttons
arg_type_handles: ArgTypeHandles,
arg_type_options: Vec<ArgumentSelectType>,
styles: WorkflowArgSelectorStyles,
/// All workflow enums accessible by this selector
/// Corresponds with a Vec of WorkflowEnumData maintained by the parent view or modal.
all_workflow_enums: HashMap<SyncId, EnumMenuItem>,
/// Vector of indices of `all_workflow_enums` to display, based on the filter query in `text_editor`
/// We also use filtered enums to display the list in alphabetical order, by sorting the filtered indices.
filtered_enums: Vec<SyncId>,
/// Vector of all workflow enums created before saving.
created_enums: Vec<SyncId>,
/// The index into the workflow enums vector of the selected enum
selected_enum: Option<SyncId>,
/// Base state when the row is loaded, used when determining if it is dirty
base_selection: ArgumentType,
/// States for the enum dropdown list
enum_menu_mouse_state: MouseStateHandle,
enum_search_clipped_scroll_state: ClippedScrollStateHandle,
}
struct EnumMenuItem {
name: String,
/// Used for determining whether the whole row has been hovered
item_row_state_handle: MouseStateHandle,
/// Used for determining if a row has been selected
select_item_state_handle: MouseStateHandle,
/// Used for determining if the edit button for a row has been clicked
edit_item_state_handle: MouseStateHandle,
}
impl EnumMenuItem {
fn new(data: &WorkflowEnumData) -> Self {
EnumMenuItem {
name: data.name.clone(),
item_row_state_handle: Default::default(),
select_item_state_handle: Default::default(),
edit_item_state_handle: Default::default(),
}
}
fn from_text(text: String) -> Self {
EnumMenuItem {
name: text,
item_row_state_handle: Default::default(),
select_item_state_handle: Default::default(),
edit_item_state_handle: Default::default(),
}
}
}
/// Styles used when rendering the modal vs. the panes workflow view
pub struct WorkflowArgSelectorStyles {
/// The input padding within an editor
pub editor_padding: Coords,
pub width: Option<f32>,
pub height: Option<f32>,
pub border_radius: f32,
/// We need to configure these colors as they differ between the workflow
/// view and the workflow modal. They are passed as closures so the colors
/// correctly update when a theme is changed.
pub dropdown_background: fn(&Appearance) -> Fill,
pub border_color: fn(&Appearance) -> Fill,
}
#[derive(Default, Clone)]
struct ArgTypeHandles {
arg_type_state_handle: ToggleMenuStateHandle,
arg_type_mouse_states: Vec<MouseStateHandle>,
}
#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr, EnumIter, Default)]
pub enum ArgumentSelectType {
#[default]
Text,
Enum,
}
impl From<ArgumentType> for ArgumentSelectType {
fn from(arg_type: ArgumentType) -> Self {
match arg_type {
ArgumentType::Text => ArgumentSelectType::Text,
ArgumentType::Enum { .. } => ArgumentSelectType::Enum,
}
}
}
impl WorkflowArgSelector {
pub fn new(
styles: WorkflowArgSelectorStyles,
all_workflow_enums: &HashMap<SyncId, WorkflowEnumData>,
ctx: &mut ViewContext<Self>,
) -> Self {
let appearance = Appearance::as_ref(ctx);
let ui_font_family: FamilyId = appearance.ui_font_family();
let text_editor = ctx.add_typed_action_view(|ctx| {
EditorView::new(
EditorOptions {
text: TextOptions {
font_size_override: Some(ARGUMENT_EDITOR_FONT_SIZE),
font_family_override: Some(ui_font_family),
..Default::default()
},
soft_wrap: true,
autogrow: true,
autocomplete_symbols: true,
// Ideally, we'd set this to PropagateAndNoOpNavigationKeys::AtBoundary, so
// that the workflow modal doesn't need to handle up/down navigation for the
// command and description editors. However, that breaks tab and shift-tab
// navigation, since those are only emitted with
// PropagateAndNoOpNavigationKeys::Never.
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
supports_vim_mode: false,
single_line: false,
enter_settings: EnterSettings {
enter: EnterAction::InsertNewLineIfMultiLine,
shift_enter: EnterAction::InsertNewLineIfMultiLine,
alt_enter: EnterAction::InsertNewLineIfMultiLine,
..Default::default()
},
..Default::default()
},
ctx,
)
});
ctx.subscribe_to_view(&text_editor, |me, _, event, ctx| {
me.handle_text_editor_event(event, ctx);
});
let arg_type_handles = ArgTypeHandles {
arg_type_mouse_states: vec![Default::default(), Default::default(), Default::default()],
..Default::default()
};
let arg_type_options = ArgumentSelectType::iter().collect();
let mut me = WorkflowArgSelector {
text_editor,
is_expanded: false,
is_disabled: false,
editor_mouse_state: Default::default(),
enum_menu_mouse_state: Default::default(),
arg_type_handles,
arg_type_options,
styles,
selected_enum: None,
base_selection: Default::default(),
all_workflow_enums: Default::default(),
filtered_enums: Default::default(),
created_enums: Default::default(),
enum_search_clipped_scroll_state: Default::default(),
};
me.set_workflow_enums(all_workflow_enums, ctx);
me.update_filtered_items(ctx);
me
}
pub fn get_selected_type(&self) -> ArgumentSelectType {
let selected_idx = self
.arg_type_handles
.arg_type_state_handle
.get_selected_idx()
.unwrap_or(0);
self.arg_type_options[selected_idx]
}
pub fn set_selected_type(
&mut self,
selected_type: ArgumentSelectType,
ctx: &mut ViewContext<Self>,
) {
self.arg_type_handles
.arg_type_state_handle
.set_selected_idx(self.get_arg_type_idx(selected_type));
ctx.notify();
}
fn get_arg_type_idx(&self, arg_type: ArgumentSelectType) -> usize {
self.arg_type_options
.iter()
.position(|type_option| *type_option == arg_type)
.unwrap_or(0)
}
pub fn get_selected_enum(&self) -> Option<SyncId> {
self.selected_enum
}
pub fn set_selected_enum(&mut self, id: Option<SyncId>, ctx: &mut ViewContext<Self>) {
if id.is_some() {
self.selected_enum = id;
self.set_selected_type(ArgumentSelectType::Enum, ctx);
} else {
self.selected_enum = None;
self.set_selected_type(ArgumentSelectType::Text, ctx);
}
self.close(ctx);
ctx.emit(WorkflowArgSelectorEvent::Edited);
}
/// Set the selected enum and base enum, used for determining if the selector is dirty
pub fn set_selected_enum_with_base_enum(
&mut self,
id: Option<SyncId>,
ctx: &mut ViewContext<Self>,
) {
if let Some(id) = id {
self.base_selection = ArgumentType::Enum { enum_id: id };
} else {
self.base_selection = ArgumentType::Text;
}
self.set_selected_enum(id, ctx);
}
pub fn get_created_enums(&self) -> Vec<SyncId> {
self.created_enums.clone()
}
pub fn set_workflow_enums(
&mut self,
workflow_enums: &HashMap<SyncId, WorkflowEnumData>,
ctx: &mut ViewContext<Self>,
) {
self.all_workflow_enums = workflow_enums
.iter()
.filter_map(|(id, enum_data)| {
if enum_data.is_shared || Some(*id) == self.get_selected_enum() {
Some((*id, EnumMenuItem::new(enum_data)))
} else {
None
}
})
.collect();
self.update_filtered_items(ctx);
ctx.notify();
}
/// Add a new enum to the workflow enums list.
/// Used when an enum is created anywhere in the parent workflow editor.
pub fn insert_enum_into_menu(
&mut self,
enum_id: SyncId,
enum_name: String,
ctx: &mut ViewContext<Self>,
) {
self.all_workflow_enums
.insert(enum_id, EnumMenuItem::from_text(enum_name));
self.created_enums.push(enum_id);
self.update_filtered_items(ctx);
ctx.notify();
}
/// Remove the enum with `enum_id` from the menu.
/// Used when an enum created in another row is edited to be "unshared".
pub fn remove_enum_from_menu(&mut self, enum_id: &SyncId, ctx: &mut ViewContext<Self>) {
// Don't remove the item if it's currently selected
let selected_enum = self.get_selected_enum();
if selected_enum == Some(*enum_id) {
return;
}
if self.all_workflow_enums.remove(enum_id).is_some() {
self.update_filtered_items(ctx);
}
ctx.notify();
}
pub fn clear_data(&mut self) {
self.base_selection = Default::default();
self.selected_enum = None;
}
pub fn is_dirty(&self, app: &AppContext) -> bool {
let text_editor_is_dirty = self.text_editor.as_ref(app).is_dirty(app);
// TODO(CLD-2167): This could also be cleaned up if we migrate away from managing the `selected_type` and `selected_enum`
// separately. Ideally, the selected enum and selected type can be tracked together using the `ArgumentType` enum.
let type_is_dirty = match self.base_selection {
ArgumentType::Text => self.get_selected_type() != ArgumentSelectType::Text,
ArgumentType::Enum { enum_id } => {
let selected_enum_dirty = self.get_selected_enum() != Some(enum_id);
self.get_selected_type() != ArgumentSelectType::Enum || selected_enum_dirty
}
};
text_editor_is_dirty || type_is_dirty
}
pub fn set_editor_text(&self, text: &str, ctx: &mut ViewContext<Self>) {
self.text_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text_with_base_buffer(text, ctx);
});
}
fn type_toggled(&self, ctx: &mut ViewContext<Self>) {
// Clear the text when toggling to the enum type, so we start with a blank filter query
if self.get_selected_type() == ArgumentSelectType::Enum {
self.text_editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
})
}
}
fn toggle_expanded(&mut self, ctx: &mut ViewContext<Self>) {
if self.is_disabled {
return;
}
self.is_expanded = !self.is_expanded;
if self.is_expanded {
ctx.focus(&self.text_editor);
}
ctx.emit(WorkflowArgSelectorEvent::ToggleExpanded);
ctx.notify();
}
fn new_enum(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(WorkflowArgSelectorEvent::NewEnum);
self.close(ctx);
}
/// Called when we want to select an existing enum from the dropdown list
fn edit_enum(&mut self, id: SyncId, ctx: &mut ViewContext<Self>) {
// Hide the dropdown menu while the enum dialog is open
self.is_expanded = false;
ctx.emit(WorkflowArgSelectorEvent::LoadEnum(id));
ctx.notify();
}
/// Update the filtered items, based on the workflow_enums list and query in the text editor.
/// This function also handles alphabetical sorting of the display, so the list is displayed alphabetically
/// while the underlying workflow_enums remains unchanged.
fn update_filtered_items(&mut self, app: &AppContext) {
let filter_query = self.text_editor.as_ref(app).buffer_text(app).to_lowercase();
self.filtered_enums = self
.all_workflow_enums
.iter()
.filter(|(_, EnumMenuItem { name, .. })| name.to_lowercase().contains(&filter_query))
.sorted_by(|(_, enum_a), (_, enum_b)| {
enum_a.name.to_lowercase().cmp(&enum_b.name.to_lowercase())
})
.map(|(id, _)| *id)
.collect();
}
pub fn disable(&mut self, ctx: &mut ViewContext<Self>) {
self.text_editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Disabled, ctx);
});
self.is_disabled = true;
}
pub fn enable(&mut self, ctx: &mut ViewContext<Self>) {
self.text_editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
});
self.is_disabled = false;
}
pub fn clear_created_enums(&mut self, ctx: &mut ViewContext<Self>) {
self.created_enums.clear();
ctx.notify();
}
pub fn close(&mut self, ctx: &mut ViewContext<Self>) {
// TODO(CLD-2167): If the selected type changes, we need to remember to clean up the separate
// `selected_enum` field. Ideally, the selected enum and selected type can be tracked together
// using the `ArgumentType` enum.
// If we've selected enum but don't have any saved enum, set back to default type
if self.get_selected_type() == ArgumentSelectType::Enum && self.selected_enum.is_none() {
self.set_selected_type(Default::default(), ctx);
}
// If we've selected text, erase any saved enum
else if self.get_selected_type() == ArgumentSelectType::Text {
self.selected_enum = None;
}
self.is_expanded = false;
ctx.emit(WorkflowArgSelectorEvent::Close);
ctx.notify();
}
fn handle_text_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Edited(_) => {
self.update_filtered_items(ctx);
ctx.emit(WorkflowArgSelectorEvent::Edited);
ctx.notify();
}
EditorEvent::Escape => self.close(ctx),
EditorEvent::Navigate(NavigationKey::Tab) => {
ctx.emit(WorkflowArgSelectorEvent::InputTab);
self.close(ctx);
}
EditorEvent::Navigate(NavigationKey::ShiftTab) => {
ctx.emit(WorkflowArgSelectorEvent::InputShiftTab);
self.close(ctx);
}
_ => (),
}
}
fn render_text_editor(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let bar = if self.is_expanded {
self.render_open_top_bar(appearance)
} else {
self.render_closed_top_bar(appearance, app)
};
let mut editor = ConstrainedBox::new(bar);
if let Some(width) = self.styles.width {
editor = editor.with_width(width);
}
if let Some(height) = self.styles.height {
editor = editor.with_height(height);
}
editor.finish()
}
// Render the closed top bar when we have a text type argument
fn render_closed_text_top_bar(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let should_show_placeholder = self.text_editor.as_ref(app).is_empty(app);
let text_label = match should_show_placeholder {
true => ARGUMENT_DEFAULT_VALUE_PLACEHOLDER_TEXT.to_string(),
false => self.text_editor.as_ref(app).buffer_text(app),
};
let editor_font_color = match should_show_placeholder {
true => appearance
.theme()
.hint_text_color(appearance.theme().background())
.into(),
false => appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
};
let font_styles = UiComponentStyles {
font_size: Some(ARGUMENT_EDITOR_FONT_SIZE),
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(editor_font_color),
..Default::default()
};
let text = Align::new(Span::new(text_label, font_styles).build().finish())
.left()
.finish();
let container = Container::new(text)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
self.styles.border_radius,
)))
.with_border(Border::all(1.).with_border_fill((self.styles.border_color)(appearance)))
.with_padding_top(self.styles.editor_padding.top)
.with_padding_bottom(self.styles.editor_padding.bottom)
.with_padding_left(self.styles.editor_padding.left)
.with_padding_right(self.styles.editor_padding.right)
.with_background(appearance.theme().background());
let hoverable = Hoverable::new(self.editor_mouse_state.clone(), |_| container.finish())
.with_cursor(Cursor::IBeam)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(WorkflowArgSelectorAction::ToggleExpanded);
});
hoverable.finish()
}
// Render the closed top bar when we have a enum type argument
fn render_closed_enum_top_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
let font_styles = UiComponentStyles {
font_size: Some(ARGUMENT_EDITOR_FONT_SIZE),
font_family_id: Some(appearance.ui_font_family()),
font_color: Some(
appearance
.theme()
.main_text_color(appearance.theme().background())
.into(),
),
..Default::default()
};
let text_label = match &self
.selected_enum
.and_then(|id| self.all_workflow_enums.get(&id))
{
Some(menu_item) => menu_item.name.clone(),
_ => Default::default(),
};
let enum_text = Align::new(Span::new(text_label, font_styles).build().finish()).finish();
let mut container = Container::new(enum_text)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
self.styles.border_radius,
)))
.with_border(Border::all(1.).with_border_fill((self.styles.border_color)(appearance)))
.with_padding_top(self.styles.editor_padding.top)
.with_padding_bottom(self.styles.editor_padding.bottom)
.with_padding_left(self.styles.editor_padding.left)
.with_padding_right(self.styles.editor_padding.right)
.with_background(appearance.theme().background());
let hoverable = Hoverable::new(self.editor_mouse_state.clone(), |state| {
if state.is_hovered() {
container = container.with_background(appearance.theme().surface_2())
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(WorkflowArgSelectorAction::ToggleExpanded);
});
hoverable.finish()
}
// Render the text editor when it is not active
fn render_closed_top_bar(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
match self.get_selected_type() {
ArgumentSelectType::Text => self.render_closed_text_top_bar(appearance, app),
ArgumentSelectType::Enum => self.render_closed_enum_top_bar(appearance),
}
}
fn render_search_icon(&self, appearance: &Appearance) -> Box<dyn Element> {
ConstrainedBox::new(
icons::Icon::SearchSmall
.to_warpui_icon(appearance.theme().active_ui_text_color())
.finish(),
)
.with_width(12.)
.with_height(12.)
.finish()
}
// Render the text editor when it is active
fn render_open_top_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut filter_bar = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
let selected_type = self.get_selected_type();
if selected_type == ArgumentSelectType::Enum {
filter_bar.add_child(
Container::new(self.render_search_icon(appearance))
.with_padding_right(6.)
.finish(),
);
}
let filter_editor = ChildView::new(&self.text_editor).finish();
filter_bar.add_child(Shrinkable::new(1., filter_editor).finish());
Container::new(filter_bar.finish())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
self.styles.border_radius,
)))
.with_border(Border::all(1.).with_border_fill((self.styles.border_color)(appearance)))
.with_padding_top(self.styles.editor_padding.top)
.with_padding_bottom(self.styles.editor_padding.bottom)
.with_padding_left(self.styles.editor_padding.left)
.with_padding_right(self.styles.editor_padding.right)
.with_background(appearance.theme().background())
.finish()
}
// Render the entire section that drops below the text editor
fn render_dropdown(&self, appearance: &Appearance) -> Box<dyn Element> {
let toggle_default = Some(self.get_arg_type_idx(ArgumentSelectType::default()));
let mut dropdown = Flex::column().with_child(
Container::new(
appearance
.ui_builder()
.toggle_menu(
self.arg_type_handles.arg_type_mouse_states.clone(),
self.arg_type_options
.iter()
.map(|arg_type| {
let label: &'static str = arg_type.into();
ToggleMenuItem::new(label)
})
.collect(),
self.arg_type_handles.arg_type_state_handle.clone(),
toggle_default,
None,
None,
None,
appearance.ui_font_size(),
Rc::new(|ctx, _, _| {
ctx.dispatch_typed_action(WorkflowArgSelectorAction::TypeToggled);
}),
)
.build()
.finish(),
)
.with_horizontal_margin(DROPDOWN_PADDING)
.with_padding_bottom(TOGGLE_MENU_BOTTOM_PADDING)
.finish(),
);
if let Some(type_dropdown) =
self.render_arg_type_dropdown(appearance, self.get_selected_type())
{
dropdown.add_child(type_dropdown);
}
Container::new(dropdown.finish())
.with_background((self.styles.dropdown_background)(appearance))
.with_vertical_padding(DROPDOWN_PADDING)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
DROPDOWN_BORDER_RADIUS,
)))
.finish()
}
// Render the type-specific portion of the dropdown
fn render_arg_type_dropdown(
&self,
appearance: &Appearance,
arg_type: ArgumentSelectType,
) -> Option<Box<dyn Element>> {
match arg_type {
ArgumentSelectType::Enum => Some(self.render_enum_menu(appearance)),
_ => None,
}
}
fn render_enum_search_items(&self, appearance: &Appearance) -> Vec<Box<dyn Element>> {
let current_enum_id = self.get_selected_enum();
let menu_items = self.filtered_enums.iter().filter_map(|id| {
self.all_workflow_enums.get(id).map(
|EnumMenuItem {
name,
item_row_state_handle,
select_item_state_handle,
edit_item_state_handle,
}| {
let enum_id = *id;
let mut menu_item = Hoverable::new(item_row_state_handle.clone(), |state| {
let button = Hoverable::new(select_item_state_handle.clone(), |_| {
Align::new(
Container::new(
Text::new_inline(
name.clone(),
appearance.ui_font_family(),
ARGUMENT_EDITOR_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.with_vertical_padding(MENU_ITEM_VERTICAL_PADDING)
.finish(),
)
.left()
.finish()
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkflowArgSelectorAction::SelectEnum(
enum_id,
));
})
.finish();
let mut flex = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., button).finish());
if state.is_hovered() {
let edit_hoverable = ConstrainedBox::new(
highlight(
icon_button(
appearance,
Icon::Pencil,
false,
edit_item_state_handle.clone(),
),
appearance,
)
.with_style(UiComponentStyles::default().set_font_color(
appearance.theme().active_ui_text_color().into(),
))
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkflowArgSelectorAction::LoadEnum(
enum_id,
));
})
.finish(),
)
.with_height(EDIT_ICON_HEIGHT);
flex.add_child(edit_hoverable.finish());
}
let mut container = Container::new(flex.finish())
.with_horizontal_padding(MENU_ITEM_HORIZONTAL_PADDING);
if Some(*id) == current_enum_id || state.is_hovered() {
container = container
.with_background(appearance.theme().foreground_button_color())
}
container.finish()
});
menu_item = menu_item.with_cursor(Cursor::PointingHand);
menu_item.finish()
},
)
});
menu_items.collect()
}
fn render_enum_menu(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut flex_col = Flex::column();
let mut menu = Hoverable::new(self.enum_menu_mouse_state.clone(), |state| {
let button = Text::new_inline(
"New".to_string(),
appearance.ui_font_family(),
ARGUMENT_EDITOR_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.finish();
let mut container = Container::new(
Flex::row()
.with_child(
Container::new(button)
.with_vertical_padding(MENU_ITEM_VERTICAL_PADDING)
.with_horizontal_padding(MENU_ITEM_HORIZONTAL_PADDING)
.finish(),
)
.with_main_axis_size(MainAxisSize::Max)
.finish(),
);
if state.is_hovered() {
container = container.with_background(appearance.theme().foreground_button_color())
};
Container::new(container.finish())
.with_horizontal_margin(MENU_ITEM_HORIZONTAL_MARGIN)
.finish()
});
menu = menu.with_cursor(Cursor::PointingHand);
menu = menu.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkflowArgSelectorAction::NewEnum);
});
flex_col.add_child(menu.finish());
let enum_menu_items = self.render_enum_search_items(appearance);
if !enum_menu_items.is_empty() {
// add a separator
flex_col.add_child(
Container::new(Empty::new().finish())
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
.with_horizontal_margin(MENU_ITEM_HORIZONTAL_PADDING)
.with_vertical_margin(MENU_ITEM_VERTICAL_PADDING)
.finish(),
);
let theme = appearance.theme();
flex_col.add_child(
ConstrainedBox::new(
ClippedScrollable::vertical(
self.enum_search_clipped_scroll_state.clone(),
Container::new(Flex::column().with_children(enum_menu_items).finish())
.with_margin_left(MENU_ITEM_HORIZONTAL_MARGIN)
.finish(),
ScrollbarWidth::Auto,
theme.disabled_text_color(theme.background()).into(),
theme.main_text_color(theme.background()).into(),
warpui::elements::Fill::None,
)
.finish(),
)
.with_max_height(ENUM_MENU_HEIGHT)
.finish(),
);
}
flex_col.finish()
}
}
#[derive(Debug, Clone)]
pub enum WorkflowArgSelectorEvent {
Close,
NewEnum,
Edited,
LoadEnum(SyncId),
ToggleExpanded,
InputTab,
InputShiftTab,
}
#[derive(Debug, Clone)]
pub enum WorkflowArgSelectorAction {
Close,
NewEnum,
LoadEnum(SyncId),
SelectEnum(SyncId),
ToggleExpanded,
TypeToggled,
}
impl View for WorkflowArgSelector {
fn ui_name() -> &'static str {
"WorkflowArgSelector"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
self.toggle_expanded(ctx);
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut stack = Stack::new()
.with_constrain_absolute_children()
.with_child(self.render_text_editor(appearance, app));
if self.is_expanded {
let dropdown = self.render_dropdown(appearance);
stack.add_positioned_overlay_child(
dropdown,
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
Dismiss::new(EventHandler::new(stack.finish()).finish())
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(WorkflowArgSelectorAction::Close))
.finish()
} else {
stack.finish()
}
}
}
impl Entity for WorkflowArgSelector {
type Event = WorkflowArgSelectorEvent;
}
impl TypedActionView for WorkflowArgSelector {
type Action = WorkflowArgSelectorAction;
fn handle_action(&mut self, action: &WorkflowArgSelectorAction, ctx: &mut ViewContext<Self>) {
match action {
WorkflowArgSelectorAction::Close => self.close(ctx),
WorkflowArgSelectorAction::ToggleExpanded => self.toggle_expanded(ctx),
WorkflowArgSelectorAction::NewEnum => self.new_enum(ctx),
WorkflowArgSelectorAction::LoadEnum(index) => self.edit_enum(*index, ctx),
WorkflowArgSelectorAction::SelectEnum(index) => {
self.set_selected_enum(Some(*index), ctx)
}
WorkflowArgSelectorAction::TypeToggled => self.type_toggled(ctx),
}
}
}
@@ -0,0 +1,324 @@
use std::collections::HashMap;
use warpui::{AppContext, SingletonEntity, ViewHandle};
use crate::{
cloud_object::{model::persistence::CloudModel, CloudObjectEventEntrypoint, Owner},
editor::EditorView,
server::{cloud_objects::update_manager::UpdateManager, ids::SyncId},
workflows::{
workflow::{Argument, ArgumentType},
workflow_enum::WorkflowEnum,
},
};
use super::{
enum_creation_dialog::{EnumCreationDialog, WorkflowEnumData},
workflow_arg_selector::WorkflowArgSelector,
};
#[derive(Debug, Clone)]
pub struct ArgumentEditorRowIndex(pub usize);
/// Trait for getting a `WorkflowArgSelector` from a component.
/// Used to make helper functions generic, working for both the
/// `WorkflowView` and `WorkflowModal` components.
pub trait ArgumentTypeEditor {
fn arg_type_editor(&self) -> &ViewHandle<WorkflowArgSelector>;
}
impl ArgumentTypeEditor for super::modal::ArgumentEditorRow {
fn arg_type_editor(&self) -> &ViewHandle<WorkflowArgSelector> {
&self.typed_default_value_editor
}
}
/// Get all workflow enums in the space, filtering to only show the shared ones
pub fn load_workflow_enums_with_owner<V>(
owner: Owner,
ctx: &mut warpui::ViewContext<V>,
) -> HashMap<SyncId, WorkflowEnumData>
where
V: warpui::View,
{
let cloud_model = CloudModel::as_ref(ctx);
cloud_model
.workflow_enums_with_owner(owner, ctx)
.filter(|workflow_enum| workflow_enum.model().string_model.is_shared)
.map(|workflow_enum| {
let enum_id = workflow_enum.id;
let enum_data = WorkflowEnumData {
name: workflow_enum.model().string_model.name.clone(),
id: enum_id,
is_shared: workflow_enum.model().string_model.is_shared,
revision_ts: workflow_enum.metadata.revision.clone(),
new_data: None,
};
(enum_id, enum_data)
})
.collect()
}
/// Helper function used to load an argument into the ArgSelector component on initialization
/// Used by both `WorkflowModal` and `WorkflowView`
pub fn load_argument_into_selector(
selector: &mut WorkflowArgSelector,
argument: &Argument,
all_workflow_enums: &mut HashMap<SyncId, WorkflowEnumData>,
ctx: &mut warpui::ViewContext<WorkflowArgSelector>,
) {
let selected_type = argument.arg_type.clone().into();
selector.set_selected_type(selected_type, ctx);
if let ArgumentType::Enum { enum_id } = argument.arg_type {
// If we have the enum in the global list, add it to the menu
// Otherwise, get the enum data from memory and make a new entry in the list for it
if let Some(enum_data) = all_workflow_enums.get(&enum_id) {
selector.insert_enum_into_menu(enum_id, enum_data.name.clone(), ctx);
} else {
// Grab the revision_ts, enum name, and shared status from the cloud model
let cloud_model = CloudModel::as_ref(ctx);
let workflow_enum_model = cloud_model.get_workflow_enum(&enum_id);
let revision_ts = workflow_enum_model.and_then(|model| model.metadata.revision.clone());
let enum_data = workflow_enum_model.map(|workflow_enum| {
let workflow_enum = &workflow_enum.model().string_model;
(workflow_enum.name.clone(), workflow_enum.is_shared)
});
// If we found an enum in memory, add the enum to the global list
if let Some((enum_name, is_shared)) = enum_data {
all_workflow_enums.insert(
enum_id,
WorkflowEnumData {
id: enum_id,
name: enum_name.clone(),
is_shared,
revision_ts,
new_data: None,
},
);
selector.insert_enum_into_menu(enum_id, enum_name, ctx);
}
}
// Set the selected enum for the selector
selector.set_selected_enum_with_base_enum(Some(enum_id), ctx);
} else {
selector.clear_data();
}
let text = match &argument.default_value {
Some(default_value) => default_value.as_str(),
None => "",
};
selector.set_editor_text(text, ctx);
}
/// Helper function used to create an argument given the workflow argument selector and text editor
/// Used by both `WorkflowModal` and `WorkflowView`
pub fn extract_typed_argument_from_selector(
argument: &Argument,
description: Option<String>,
type_selector: &WorkflowArgSelector,
text_editor: &EditorView,
app: &AppContext,
) -> Argument {
let id = type_selector.get_selected_enum();
// If we have arg type data with an enum ID, use that as our type, otherwise text.
let (arg_type, default_value) = match id {
Some(enum_id) => (
ArgumentType::Enum { enum_id },
None, // we haven't implemented default value for enums
),
None => (
ArgumentType::Text,
match text_editor.is_empty(app) {
true => None,
false => Some(text_editor.buffer_text(app)),
},
),
};
Argument {
name: argument.name.clone(),
description,
default_value,
arg_type,
}
}
/// Given arg type data, a space, and a ViewContext, saves the data represented by arg type data to the cloud.
pub fn save_enum<V>(
enum_data: &WorkflowEnumData,
owner: Option<Owner>,
ctx: &mut warpui::ViewContext<V>,
) where
V: warpui::View,
{
let Some(variants) = enum_data.new_data.clone() else {
return;
};
let workflow_enum = WorkflowEnum {
name: enum_data.name.clone(),
is_shared: true,
variants,
};
// Depending on the type of ID, create or update the relevant objects.
match enum_data.id {
SyncId::ClientId(client_id) => {
if let Some(owner) = owner {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_workflow_enum(
workflow_enum,
owner,
client_id,
CloudObjectEventEntrypoint::Unknown,
true,
ctx,
);
});
}
}
SyncId::ServerId(_) => {
// We will issue enum update requests here
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.update_workflow_enum(
workflow_enum,
enum_data.id,
enum_data.revision_ts.clone(),
ctx,
);
})
}
}
}
/// Create a new enum after closing the enum dialog
pub fn create_enum<V, T>(
enum_data: &WorkflowEnumData,
all_workflow_enums: &mut HashMap<SyncId, WorkflowEnumData>,
arguments_rows: &[T],
pending_argument_editor_row: &mut Option<ArgumentEditorRowIndex>,
ctx: &mut warpui::ViewContext<V>,
) where
T: ArgumentTypeEditor,
V: warpui::View,
{
let enum_id = enum_data.id;
let enum_name = enum_data.name.clone();
// Add the data to the global list of enums
all_workflow_enums.insert(enum_id, enum_data.clone());
// Add the new enum to each argument row's list
if enum_data.is_shared {
arguments_rows.iter().for_each(|row| {
row.arg_type_editor().update(ctx, |editor, ctx| {
editor.insert_enum_into_menu(enum_id, enum_name.clone(), ctx);
})
});
}
// Update the relevant row with the new index of the selected enum
if let Some(ArgumentEditorRowIndex(index)) = pending_argument_editor_row {
arguments_rows[*index]
.arg_type_editor()
.update(ctx, |selector, ctx| {
// Insert into the menu, which we might not have done earlier if the enum is not shared
if !enum_data.is_shared {
selector.insert_enum_into_menu(enum_id, enum_name.clone(), ctx);
}
selector.set_selected_enum(Some(enum_id), ctx);
});
}
}
/// Edit an enum after closing the enum dialog
pub fn edit_enum<V, T>(
enum_data: &WorkflowEnumData,
did_visibility_change: bool,
all_workflow_enums: &mut HashMap<SyncId, WorkflowEnumData>,
arguments_rows: &[T],
pending_argument_editor_row: &mut Option<ArgumentEditorRowIndex>,
ctx: &mut warpui::ViewContext<V>,
) where
T: ArgumentTypeEditor,
V: warpui::View,
{
let enum_id = enum_data.id;
let enum_name = &enum_data.name;
// Replace this item in the global enum map
all_workflow_enums.insert(enum_data.id, enum_data.clone());
// Update the enum to each argument row's list, in case its name was updated, if it is shared
if enum_data.is_shared {
arguments_rows.iter().for_each(|row| {
row.arg_type_editor().update(ctx, |editor, ctx| {
editor.insert_enum_into_menu(enum_id, enum_name.clone(), ctx);
})
});
}
// Otherwise, remove the enum from the dropdown list for every row if it is newly "unshared"
else if !enum_data.is_shared && did_visibility_change {
arguments_rows.iter().for_each(|row| {
row.arg_type_editor().update(ctx, |editor, ctx| {
editor.remove_enum_from_menu(&enum_id, ctx);
})
});
}
// Update the relevant row with the selected index of their enum
if let Some(ArgumentEditorRowIndex(index)) = pending_argument_editor_row {
arguments_rows[*index]
.arg_type_editor()
.update(ctx, |selector, ctx| {
// Insert into the menu, which we might have undone earlier if the enum is unshared
if !enum_data.is_shared {
selector.insert_enum_into_menu(enum_id, enum_name.clone(), ctx);
}
});
}
}
/// Load in an enum to the enum dialog.
/// Returns a boolean, `true` if we want to show the enum dialog
pub fn load_enum<V>(
id: &SyncId,
all_workflow_enums: &HashMap<SyncId, WorkflowEnumData>,
enum_creation_dialog: &ViewHandle<EnumCreationDialog>,
ctx: &mut warpui::ViewContext<V>,
) -> bool
where
V: warpui::View,
{
match all_workflow_enums.get(id) {
// If we have local variants for this enum, pass them in
Some(WorkflowEnumData {
name,
is_shared,
new_data: Some(new_data),
..
}) => {
enum_creation_dialog.update(ctx, |dialog, ctx| {
dialog.load_from_data(name, *id, *is_shared, new_data, ctx);
});
true
}
// We don't have the variants for this enum
Some(WorkflowEnumData { .. }) => {
enum_creation_dialog.update(ctx, |dialog, ctx| {
dialog.load_from_cloud_model(*id, ctx);
});
true
}
_ => {
log::error!("Attempting to select an enum that cannot be found");
false
}
}
}