Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¬ebook_id)
|
||||
.ok_or_else(|| anyhow!("no notebook for {notebook_id}"))?;
|
||||
let internal_data = ¬ebook.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(¤t_name);
|
||||
current_path.set_extension(extension);
|
||||
let file = open_options.open(¤t_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(¤t_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;
|
||||
@@ -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(¬ebook_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");
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -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))))");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod modal;
|
||||
mod modal_body;
|
||||
mod nodes;
|
||||
mod queue;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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(¤t_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);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user