use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use itertools::Itertools; use pathfinder_geometry::vector::vec2f; use string_offset::CharOffset; use galaxy_core::ui::theme::Fill; use galaxy_editor::editor::NavigationKey; use galaxyui::clipboard::ClipboardContent; use galaxyui::elements::{ Align, Border, ChildAnchor, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, ScrollbarWidth, Shrinkable, Stack, }; use galaxyui::fonts::{FamilyId, Weight}; use galaxyui::platform::Cursor; use galaxyui::presenter::ChildView; use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment}; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::{ AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, UpdateView, View, ViewContext, ViewHandle, }; use super::arguments::ArgumentsState; use super::enum_creation_dialog::{EnumCreationDialog, EnumCreationDialogEvent, WorkflowEnumData}; use super::workflow_arg_selector::{ WorkflowArgSelector, WorkflowArgSelectorEvent, WorkflowArgSelectorStyles, }; use super::workflow_arg_type_helpers::{self, ArgumentEditorRowIndex}; use crate::appearance::Appearance; use crate::auth::UserUid; use crate::cloud_object::breadcrumbs::{ContainingObject, ContainingObjectKind}; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; use crate::cloud_object::{CloudObject, CloudObjectEventEntrypoint, ObjectType, Owner, Revision}; use crate::drive::cloud_object_styling::warp_drive_icon_color; use crate::drive::items::WarpDriveItemId; use crate::drive::{CloudObjectTypeAndId, DriveObjectType}; use crate::editor::{ EditorOptions, EditorView, EnterAction, EnterSettings, Event as EditorEvent, InteractionState, PlainTextEditorViewAction as EditorAction, PropagateAndNoOpNavigationKeys, TextOptions, TextStyleOperation, }; use crate::menu::{Event, Menu, MenuItem, MenuItemFields}; use crate::network::NetworkStatus; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::ids::{ClientId, ServerId, SyncId}; use crate::server::server_api::ai::AIClient; use crate::themes::theme::AnsiColorIdentifier; use crate::ui_components::blended_colors; use crate::ui_components::breadcrumb::{self, BreadcrumbState}; use crate::ui_components::buttons::icon_button; use crate::ui_components::dialog::{dialog_styles, Dialog}; use crate::ui_components::icons::{self, Icon, ICON_DIMENSIONS}; use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection}; use crate::workflows::workflow::{Argument, Workflow}; use crate::workflows::CloudWorkflow; const BREADCRUMBS_VERTICAL_MARGIN: f32 = 6.; const MODAL_WIDTH: f32 = 900.; const MODAL_VERTICAL_PADDING: f32 = 20.; const MODAL_VERTICAL_MARGIN: f32 = 50.; const MODAL_HORIZONTAL_MARGIN: f32 = 28.; const MODAL_HORIZONTAL_PADDING: f32 = 16.; const MODAL_BORDER_RADIUS: f32 = 8.; const DESCRIPTION_FONT_SIZE: f32 = 14.; const DESCRIPTION_EDITOR_TOP_PADDING: f32 = 12.; const DESCRIPTION_EDITOR_MAX_HEIGHT: f32 = 100.; const COMMAND_EDITOR_PADDING: f32 = 16.; const CONTENT_EDITOR_FONT_SIZE: f32 = 14.; const COMMAND_EDITOR_MIN_LINES: f32 = 3.; const ARGUMENT_INPUT_WIDTH: f32 = 300.; const ARGUMENT_INPUT_PADDING: f32 = 12.; const ARGUMENT_INPUT_MARGIN: f32 = 8.; const ARGUMENT_EDITOR_FONT_SIZE: f32 = 14.; 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 = 460.; const AI_ASSIST_BUTTON_SIZE: f32 = 96.; const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto; const TITLE_PLACEHOLDER_TEXT: &str = "Untitled workflow"; const DESCRIPTION_PLACEHOLDER_TEXT: &str = "Add a description"; const COMMAND_EDITOR_PLACEHOLDER_TEXT: &str = "echo \"Hello {{your_name}}\" # insert arguments with curly braces\n# enter a single-line command or an entire shell script"; const ARGUMENT_BUTTON_TEXT: &str = "New argument"; const ARGUMENT_DESCRIPTION_PLACEHOLDER_TEXT: &str = "Description"; const ARGUMENT_DEFAULT_VALUE_PLACEHOLDER_TEXT: &str = "Default value (optional)"; const SAVE_BUTTON_TEXT: &str = "Save workflow"; const AI_ASSIST_BUTTON_TEXT: &str = "Autofill"; const AI_ASSIST_LOADING_TEXT: &str = "Loading"; const DEFAULT_ARGUMENT_PREFIX: &str = "argument"; const UNSAVED_CHANGES_TEXT: &str = "You have unsaved changes."; const KEEP_EDITING_TEXT: &str = "Keep editing"; const DISCARD_CHANGES_TEXT: &str = "Discard changes"; #[derive(Default)] struct MouseStateHandles { close_modal_state: MouseStateHandle, new_argument_state: MouseStateHandle, save_workflow_state: MouseStateHandle, keep_editing_state: MouseStateHandle, discard_changes_state: MouseStateHandle, ai_assist_state: MouseStateHandle, ai_assist_tool_tip: MouseStateHandle, menu_state: MouseStateHandle, } pub(super) enum AiAssistState { PreRequest, RequestInFlight, Generated, } /// Represents a particular row for editing a single argument. /// Note that maintaining the order is very important. A new argument can be /// inserted anywhere in the command, or the name of an arg can change, but we /// should try to preserve as much state as possible (e.g., the description /// value should be maintained even if we change the name of the argument). pub(super) struct ArgumentEditorRow { name: String, pub(super) description_editor: ViewHandle, pub(super) default_value_editor: ViewHandle, pub(super) typed_default_value_editor: ViewHandle, } pub struct WorkflowModal { is_open: bool, owner: Option, initial_folder_id: Option, /// Only present if the workflow already exists in the cloud. workflow_id: Option, button_mouse_states: MouseStateHandles, errors: WorkflowEditorErrorState, pub(super) title_editor: ViewHandle, pub(super) description_editor: ViewHandle, pub(super) content_editor: ViewHandle, /// How many times the "add argument" button was clicked. We use this value /// to append a number to the default argument name (argument_1, argument_2, /// etc.). default_argument_id: usize, pub(super) arguments_state: ArgumentsState, pub(super) arguments_rows: Vec, show_unsaved_changes_dialog: bool, revision_ts: Option, pub(super) ai_client: Arc, pub(super) ai_metadata_assist_state: AiAssistState, breadcrumbs: Option>>, /// ID of the breadcrumb space/folder a user clicked on before the unsaved dialog popped up clicked_breadcrumb: Option, menu: ViewHandle>, menu_open: bool, arguments_clipped_scroll_state: ClippedScrollStateHandle, pending_argument_editor_row: Option, show_enum_creation_dialog: bool, enum_creation_dialog: ViewHandle, all_workflow_enums: HashMap, } #[derive(Clone, Debug)] pub enum WorkflowModalAction { AddArgument, Close, Save, CloseUnsavedChangesDialog, ForceClose, AiAssist, ViewInWarpDrive(WarpDriveItemId), OpenOverflowMenu, CopyObjectToClipboard, TrashObject, } pub enum WorkflowModalEvent { Close, UpdatedWorkflow(SyncId), AiAssistError(String), AiAssistUpgradeError(Option, UserUid), ViewInWarpDrive(WarpDriveItemId), } /// A grouping of various error states the modal can be in. Any of these being /// `true` prevents the save button from being clickable. #[derive(Default)] struct WorkflowEditorErrorState { /// The content must not be whitespace-only. content_empty_error: bool, /// The content must not have any arguments that are invalid (e.g. start /// with a numeric number or special character like *). invalid_argument_error: bool, } impl WorkflowEditorErrorState { pub fn new() -> Self { Self { content_empty_error: true, invalid_argument_error: false, } } pub fn has_any_error(&self) -> bool { self.content_empty_error || self.invalid_argument_error } } impl WorkflowModal { pub fn new(ai_client: Arc, ctx: &mut ViewContext) -> Self { let appearance = Appearance::as_ref(ctx); let header_font_size = appearance.header_font_size(); let ui_font_family = appearance.ui_font_family(); let title_editor: ViewHandle = Self::create_editor_handle( ctx, Some(header_font_size), Some(ui_font_family), Some(TITLE_PLACEHOLDER_TEXT), false, /* vim_keybindings */ true, /* single_line */ ); ctx.subscribe_to_view(&title_editor, |me, _, event, ctx| { me.handle_title_editor_event(event, ctx); }); let description_editor = Self::create_editor_handle( ctx, Some(DESCRIPTION_FONT_SIZE), Some(ui_font_family), Some(DESCRIPTION_PLACEHOLDER_TEXT), false, /* vim_keybindings */ false, /* single_line */ ); ctx.subscribe_to_view(&description_editor, |me, _, event, ctx| { me.handle_description_editor_event(event, ctx); }); let content_editor = Self::create_editor_handle( ctx, Some(CONTENT_EDITOR_FONT_SIZE), None, Some(COMMAND_EDITOR_PLACEHOLDER_TEXT), true, /* vim_keybindings */ false, /* single_line */ ); ctx.subscribe_to_view(&content_editor, |me, _, event, ctx| { me.handle_content_editor_event(event, ctx); }); let cloud_model = CloudModel::handle(ctx); ctx.subscribe_to_model(&cloud_model, |me, _, event, ctx| { me.handle_cloud_model_event(event, ctx); }); let menu = ctx.add_typed_action_view(|_ctx| { Menu::new() .prevent_interaction_with_other_elements() .with_drop_shadow() }); ctx.subscribe_to_view(&menu, |me, _, event, ctx| { me.handle_menu_event(event, ctx); }); let enum_creation_dialog = ctx.add_typed_action_view(EnumCreationDialog::new); ctx.subscribe_to_view(&enum_creation_dialog, |me, _, event, ctx| { me.handle_enum_creation_dialog_event(event, ctx); }); Self { is_open: false, owner: None, initial_folder_id: None, workflow_id: None, button_mouse_states: Default::default(), errors: WorkflowEditorErrorState::new(), title_editor, description_editor, content_editor, default_argument_id: 0, arguments_state: Default::default(), arguments_rows: Vec::new(), show_unsaved_changes_dialog: false, revision_ts: None, ai_client, ai_metadata_assist_state: AiAssistState::PreRequest, breadcrumbs: Default::default(), clicked_breadcrumb: None, menu, menu_open: false, arguments_clipped_scroll_state: Default::default(), pending_argument_editor_row: None, show_enum_creation_dialog: false, enum_creation_dialog, all_workflow_enums: Default::default(), } } pub(super) fn disable_editors(&mut self, ctx: &mut ViewContext) { self.description_editor.update(ctx, |view, ctx| { view.set_interaction_state(InteractionState::Disabled, ctx) }); self.title_editor.update(ctx, |view, ctx| { view.set_interaction_state(InteractionState::Disabled, ctx) }); self.content_editor.update(ctx, |view, ctx| { view.set_interaction_state(InteractionState::Disabled, ctx) }); self.arguments_rows.iter().for_each(|row| { row.description_editor.update(ctx, |editor, ctx| { editor.set_interaction_state(InteractionState::Disabled, ctx); }); row.default_value_editor.update(ctx, |editor, ctx| { editor.set_interaction_state(InteractionState::Disabled, ctx); }); row.typed_default_value_editor.update(ctx, |selector, ctx| { selector.disable(ctx); }) }); } pub(super) fn enable_editors(&mut self, ctx: &mut ViewContext) { self.description_editor.update(ctx, |view, ctx| { view.set_interaction_state(InteractionState::Editable, ctx) }); self.title_editor.update(ctx, |view, ctx| { view.set_interaction_state(InteractionState::Editable, ctx) }); self.content_editor.update(ctx, |view, ctx| { view.set_interaction_state(InteractionState::Editable, ctx) }); self.arguments_rows.iter().for_each(|row| { row.description_editor.update(ctx, |editor, ctx| { editor.set_interaction_state(InteractionState::Editable, ctx); }); row.default_value_editor.update(ctx, |editor, ctx| { editor.set_interaction_state(InteractionState::Editable, ctx); }); row.typed_default_value_editor.update(ctx, |selector, ctx| { selector.enable(ctx); }) }); } fn create_editor_handle( ctx: &mut ViewContext, font_size_override: Option, font_family_override: Option, placeholder_text: Option<&str>, vim_keybindings: bool, single_line: bool, ) -> ViewHandle { ctx.add_typed_action_view(|ctx| { let mut editor = EditorView::new( EditorOptions { text: TextOptions { font_size_override, font_family_override, ..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 // content 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: vim_keybindings, single_line, enter_settings: EnterSettings { enter: EnterAction::InsertNewLineIfMultiLine, shift_enter: EnterAction::InsertNewLineIfMultiLine, alt_enter: EnterAction::InsertNewLineIfMultiLine, ..Default::default() }, ..Default::default() }, ctx, ); if let Some(text) = placeholder_text { editor.set_placeholder_text(text, ctx); } editor }) } /// Opens the modal with no preexisting workflow. /// This represents the creation experience; saving this workflow will add /// a new one to the space specified. pub fn open_with_new( &mut self, owner: Owner, initial_folder_id: Option, ctx: &mut ViewContext, ) { self.is_open = true; self.initial_folder_id = initial_folder_id; self.owner = Some(owner); self.workflow_id = None; self.compute_breadcrumbs(ctx); self.all_workflow_enums = workflow_arg_type_helpers::load_workflow_enums_with_owner(owner, ctx); ctx.notify(); } /// Populate the modal with the data of a [`Workflow`] struct #[allow(dead_code)] fn populate(&mut self, workflow: Workflow, ctx: &mut ViewContext) { // Sanitize the arguments generated for the workflow by removing any illegal characters. // Necessary since Warp AI command search sometimes provides arguments in an invalid argument format. let mut sanitized_content = workflow.content().to_string(); let sanitized_arguments = workflow .arguments() .iter() .map(|argument| { let new_argument = argument .name .replace('.', "_") .chars() .skip_while(|c| c.is_numeric()) .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .collect::(); // Replace the old argument name in the path with the cleaned one sanitized_content = sanitized_content.replace(&argument.name, &new_argument); Argument { name: new_argument, ..argument.clone() } }) .collect::>(); self.title_editor.update(ctx, |editor, ctx| { editor.set_buffer_text_with_base_buffer(workflow.name(), ctx); }); self.description_editor.update(ctx, |editor, ctx| { editor.set_buffer_text_with_base_buffer( workflow .description() .map(String::as_str) .unwrap_or_default(), ctx, ); }); self.content_editor.update(ctx, |editor, ctx| { editor.set_buffer_text_with_base_buffer(&sanitized_content, ctx); }); // note: normally, we wouldn't have to do this, since editing the content // 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, sanitized_content); self.update_arguments_rows(ctx); sanitized_arguments .iter() .enumerate() .for_each(|(index, argument)| { if let Some(description) = &argument.description { self.arguments_rows[index] .description_editor .update(ctx, |editor, ctx| { editor.set_buffer_text_with_base_buffer(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_with_base_buffer(default_value.as_str(), ctx); }); } self.arguments_rows[index] .typed_default_value_editor .update(ctx, |selector, ctx| { workflow_arg_type_helpers::load_argument_into_selector( selector, argument, &mut self.all_workflow_enums, ctx, ); }); }); } pub fn close(&mut self, force: bool, ctx: &mut ViewContext) { if !force && self.should_show_unsaved_changes_dialog(ctx) { self.show_unsaved_changes_dialog(ctx); return; } self.hide_unsaved_changes_dialog(ctx); self.ai_metadata_assist_state = AiAssistState::PreRequest; self.is_open = false; self.owner = None; self.initial_folder_id = None; self.workflow_id = None; self.revision_ts = None; self.title_editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); editor.set_base_buffer_text("".to_string(), ctx); }); self.description_editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); editor.set_base_buffer_text("".to_string(), ctx); }); self.content_editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); editor.set_base_buffer_text("".to_string(), ctx); }); self.default_argument_id = 0; self.arguments_rows.clear(); self.arguments_state = Default::default(); ctx.emit(WorkflowModalEvent::Close); } fn show_unsaved_changes_dialog(&mut self, ctx: &mut ViewContext) { self.show_unsaved_changes_dialog = true; self.disable_editors(ctx); ctx.notify(); } fn hide_unsaved_changes_dialog(&mut self, ctx: &mut ViewContext) { self.show_unsaved_changes_dialog = false; self.enable_editors(ctx); ctx.notify(); } fn show_enum_creation_dialog(&mut self, ctx: &mut ViewContext) { self.show_enum_creation_dialog = true; self.disable_editors(ctx); ctx.notify(); } fn hide_enum_creation_dialog(&mut self, ctx: &mut ViewContext) { self.show_enum_creation_dialog = false; self.enable_editors(ctx); ctx.notify(); } pub fn is_open(&self) -> bool { self.is_open } fn is_dirty(&self, app: &AppContext) -> bool { let title_is_dirty = self.title_editor.as_ref(app).is_dirty(app); let description_is_dirty = self.description_editor.as_ref(app).is_dirty(app); let content_is_dirty = self.content_editor.as_ref(app).is_dirty(app); let any_argument_editor_is_dirty = self.arguments_rows.iter().any(|row| { let selector_is_dirty = { let editor = row.typed_default_value_editor.as_ref(app); let editor_is_dirty = editor.is_dirty(app); let enum_is_dirty = editor .get_selected_enum() .and_then(|id| self.all_workflow_enums.get(&id)) .map(|enum_data| enum_data.new_data.is_some()) .unwrap_or(false); editor_is_dirty || enum_is_dirty }; selector_is_dirty || row.default_value_editor.as_ref(app).is_dirty(app) || row.description_editor.as_ref(app).is_dirty(app) }); title_is_dirty || description_is_dirty || content_is_dirty || any_argument_editor_is_dirty } fn is_empty(&self, app: &AppContext) -> bool { let title_is_empty = self.title_editor.as_ref(app).is_empty(app); let description_is_empty = self.description_editor.as_ref(app).is_empty(app); let content_is_empty = self.content_editor.as_ref(app).is_empty(app); title_is_empty && description_is_empty && content_is_empty } fn view_in_warp_drive(&mut self, id: WarpDriveItemId, ctx: &mut ViewContext) { ctx.emit(WorkflowModalEvent::ViewInWarpDrive(id)); self.close(false /* force */, ctx); self.clicked_breadcrumb = None; } fn handle_menu_event(&mut self, event: &Event, ctx: &mut ViewContext) { if let Event::Close { .. } = event { self.close_overflow_menu(ctx); } } fn open_overflow_menu(&mut self, ctx: &mut ViewContext) { let menu_items = self.menu_items(ctx); // We need to set items every time the menu is opened because we don't know whether the Trash action is available ctx.update_view(&self.menu, |menu, ctx| { menu.set_items(menu_items, ctx); }); if !self.menu_open { self.menu_open = true; ctx.focus(&self.menu); ctx.notify(); } } pub fn close_overflow_menu(&mut self, ctx: &mut ViewContext) { self.menu_open = false; ctx.notify(); } // Identical to logic in DriveIndexAction::CopyObjectToClipboard fn copy_object_to_clipboard(&mut self, ctx: &mut ViewContext) { if let Some(workflow_id) = self.workflow_id { let cloud_model = CloudModel::as_ref(ctx); let object = cloud_model.get_by_uid(&workflow_id.uid()); if let Some(object) = object { match object.object_type() { ObjectType::Workflow => { let workflow: Option<&CloudWorkflow> = object.into(); if let Some(workflow) = workflow { let content = workflow.model().data.content().to_owned(); ctx.clipboard().write(ClipboardContent::plain_text(content)); } } ObjectType::Notebook | ObjectType::Folder | ObjectType::GenericStringObject(_) => {} } } self.close_overflow_menu(ctx); } } fn trash_object(&mut self, ctx: &mut ViewContext) { if let Some(workflow_id) = self.workflow_id { self.close_overflow_menu(ctx); // Close workflow editor self.close(true, ctx); UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.trash_object( CloudObjectTypeAndId::from_id_and_type(workflow_id, ObjectType::Workflow), ctx, ); }); } } fn menu_items(&self, app: &AppContext) -> Vec> { let mut menu_items = Vec::new(); // Add "Copy workflow text" to menu menu_items.push( MenuItemFields::new("Copy workflow text") .with_on_select_action(WorkflowModalAction::CopyObjectToClipboard) .with_icon(Icon::CopyMenuItem) .into_item(), ); // Add "Trash" to menu if self.is_online(app) { menu_items.push( MenuItemFields::new("Trash") .with_on_select_action(WorkflowModalAction::TrashObject) .with_icon(Icon::Trash) .into_item(), ); } menu_items } pub fn should_show_unsaved_changes_dialog(&self, app: &AppContext) -> bool { // if we don't have a workflow_id, then we're creating a new workflow (not updating an // existing one). in that case, if there's no content in any editor, we bypass the unsaved // changes dialog entirely and allow them to close it. if self.workflow_id.is_none() && self.is_empty(app) { false } else { self.is_dirty(app) } } // If the title isn't supplied by the user, we use the first two words of the content as the title. fn truncate_content_for_title(&self, content: String) -> String { content.split_ascii_whitespace().take(2).join(" ") } fn save_workflow_and_close(&mut self, ctx: &mut ViewContext) { let content = self.content_editor.as_ref(ctx).buffer_text(ctx); let title_in_editor = self.title_editor.as_ref(ctx).buffer_text(ctx); let workflow_name = if title_in_editor.is_empty() { self.truncate_content_for_title(content.clone()) } else { title_in_editor }; let workflow_description = self.description_editor.as_ref(ctx).buffer_text(ctx); self.save_argument_objects(ctx); let mut workflow = Workflow::new(workflow_name, content); workflow = workflow.with_arguments(self.arguments_with_metadata(ctx)); if !workflow_description.is_empty() { workflow = workflow.with_description(workflow_description); } match (self.workflow_id, self.owner) { (Some(workflow_id), None) => { UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { update_manager.update_workflow(workflow, workflow_id, self.revision_ts.clone(), ctx); }); ctx.emit(WorkflowModalEvent::UpdatedWorkflow(workflow_id)); } (None, Some(owner)) => { UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { update_manager.create_workflow( workflow, owner, self.initial_folder_id, ClientId::default(), CloudObjectEventEntrypoint::Unknown, true, ctx, ); }); } _ => log::error!("Only one of a workflow ID or space can be specified for saving workflows, but both or neither were specified instead") } self.close(true, ctx); } /// Merges the arguments from arguments_state with the descriptions/default /// values that we store in the view layer. fn arguments_with_metadata(&mut self, ctx: &mut ViewContext) -> Vec { self.arguments_state .arguments .iter() .enumerate() .filter_map(|(index, argument)| { self.arguments_rows.get(index).map(|argument_row| { let description_editor = argument_row.description_editor.as_ref(ctx); let description = match description_editor.is_empty(ctx) { true => None, false => Some(description_editor.buffer_text(ctx)), }; let type_selector = argument_row.typed_default_value_editor.as_ref(ctx); let text_editor = type_selector.text_editor.as_ref(ctx); workflow_arg_type_helpers::extract_typed_argument_from_selector( argument, description, type_selector, text_editor, ctx, ) }) }) .collect() } /// Iterates through the argument rows and creates/updates any relevant argument objects on the server. /// Returns a mapping of argument row indices to the ID of relevant objects, to be used by `arguments_with_metadata` /// when creating or updating a `Workflow` object. fn save_argument_objects(&self, ctx: &mut ViewContext) { let mut sent_requests: HashSet = HashSet::new(); let owner = match (self.workflow_id, self.owner) { (Some(workflow_id), None) => CloudModel::as_ref(ctx) .get_workflow(&workflow_id) .map(|workflow| workflow.permissions.owner), (None, Some(owner)) => Some(owner), _ => { log::error!("Only one of a workflow ID or space can be specified"); None } }; self.arguments_rows.iter().for_each(|argument_row| { let type_selector = argument_row.typed_default_value_editor.as_ref(ctx); // Check to see if we have enum data for this id, then create a request for it if let Some(enum_data) = type_selector .get_selected_enum() .and_then(|id| self.all_workflow_enums.get(&id)) { if enum_data.new_data.is_some() && !sent_requests.contains(&enum_data.id) { workflow_arg_type_helpers::save_enum(enum_data, owner, ctx); // Make sure we aren't sending duplicate requests // We choose to do it this way so we don't end up creating/updating enums that aren't used sent_requests.insert(enum_data.id); } } }); } /// Adds a default argument, either at the end with a placeholder name if /// no text is selected, or in place with the selected text as the arg name. fn add_argument(&mut self, ctx: &mut ViewContext) { self.content_editor.update(ctx, |editor, ctx| { let selected_text = editor.selected_text(ctx); let argument_name = if selected_text.is_empty() { self.default_argument_id += 1; format!("{}_{}", DEFAULT_ARGUMENT_PREFIX, self.default_argument_id) } else { String::from(selected_text.trim()) }; // this renders out as e.g. `{{argument_X}}` - we need 5 curly brace // pairs because we're formatting in rust AND using them literally let formatted_argument = format!("{{{{{argument_name}}}}}"); editor.user_initiated_insert( formatted_argument.as_str(), EditorAction::SystemInsert, ctx, ); }); } fn handle_title_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { match event { EditorEvent::Navigate(NavigationKey::Tab) => { ctx.focus(&self.description_editor); } EditorEvent::Navigate(NavigationKey::ShiftTab) => match self.arguments_rows.last() { Some(row) => ctx.focus(&row.default_value_editor), None => ctx.focus(&self.content_editor), }, EditorEvent::Escape => self.close(false, ctx), _ => {} } } fn handle_description_editor_event( &mut self, event: &EditorEvent, ctx: &mut ViewContext, ) { match event { EditorEvent::Edited(_) => ctx.notify(), EditorEvent::Navigate(NavigationKey::Tab) => ctx.focus(&self.content_editor), EditorEvent::Navigate(NavigationKey::ShiftTab) => ctx.focus(&self.title_editor), EditorEvent::Navigate(NavigationKey::Up) => self .description_editor .update(ctx, |input, ctx| input.move_up(ctx)), EditorEvent::Navigate(NavigationKey::Down) => self .description_editor .update(ctx, |input, ctx| input.move_down(ctx)), EditorEvent::Escape => self.close(false, ctx), _ => {} } } // This method computes the breadcrumb data for the workflow editor. It should be called // every time either the cloud model or workflow ID changes. fn compute_breadcrumbs(&mut self, ctx: &mut ViewContext) { self.breadcrumbs = self.workflow_id.and_then(|workflow_id| { CloudModel::as_ref(ctx) .get_workflow(&workflow_id) .map(|workflow| { workflow .containing_objects_path(ctx) .into_iter() .map(BreadcrumbState::new) .collect::>() }) }); ctx.notify() } fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ViewContext) { match event { CloudModelEvent::ObjectMoved { type_and_id, .. } | CloudModelEvent::ObjectPermissionsUpdated { type_and_id, .. } => { // Update breadcrumbs if a teammate has moved the workflow elsewhere, or if it's // been shared. if let Some(workflow_id) = self.workflow_id { // Check that it's the currently active/open workflow if *type_and_id == CloudObjectTypeAndId::from_id_and_type(workflow_id, ObjectType::Workflow) { self.compute_breadcrumbs(ctx); } } } CloudModelEvent::NotebookEditorChangedFromServer { .. } | CloudModelEvent::ObjectUpdated { .. } | CloudModelEvent::ObjectTrashed { .. } | CloudModelEvent::ObjectUntrashed { .. } | CloudModelEvent::ObjectCreated { .. } | CloudModelEvent::ObjectDeleted { .. } | CloudModelEvent::ObjectForceExpanded { .. } | CloudModelEvent::ObjectSynced { .. } | CloudModelEvent::InitialLoadCompleted => {} } } fn handle_content_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { match event { EditorEvent::Edited(_) => { let current_content = self .content_editor .read(ctx, |editor, ctx| editor.buffer_text(ctx)); self.errors.content_empty_error = current_content.trim().is_empty(); self.arguments_state = ArgumentsState::for_command_workflow( &self.arguments_state, current_content.clone(), ); self.update_arguments_rows(ctx); self.clear_content_formatting(current_content.chars().count(), ctx); self.apply_error_underlining_to_content(ctx); self.apply_argument_highlighting_to_content(ctx); self.errors.invalid_argument_error = !self .arguments_state .invalid_arguments_char_ranges .is_empty(); ctx.notify(); } // when the editor supports tab completions, we'll need to change this logic EditorEvent::Navigate(NavigationKey::Tab) => match self.arguments_rows.first() { Some(row) => ctx.focus(&row.description_editor), None => ctx.focus(&self.title_editor), }, EditorEvent::Navigate(NavigationKey::ShiftTab) => ctx.focus(&self.description_editor), EditorEvent::Navigate(NavigationKey::Up) => self .content_editor .update(ctx, |input, ctx| input.move_up(ctx)), EditorEvent::Navigate(NavigationKey::Down) => self .content_editor .update(ctx, |input, ctx| input.move_down(ctx)), EditorEvent::Escape => self.close(false, ctx), _ => {} } } fn handle_enum_creation_dialog_event( &mut self, event: &EnumCreationDialogEvent, ctx: &mut ViewContext, ) { match event { EnumCreationDialogEvent::Close => { self.hide_enum_creation_dialog(ctx); // Reopen the dropdown after the enum dialog is closed if let Some(ArgumentEditorRowIndex(index)) = self.pending_argument_editor_row.take() { ctx.focus(&self.arguments_rows[index].typed_default_value_editor); } } EnumCreationDialogEvent::CreateEnum(enum_data) => { workflow_arg_type_helpers::create_enum( enum_data, &mut self.all_workflow_enums, &self.arguments_rows, &mut self.pending_argument_editor_row, ctx, ); } EnumCreationDialogEvent::EditEnum(enum_data, did_visibility_change) => { workflow_arg_type_helpers::edit_enum( enum_data, *did_visibility_change, &mut self.all_workflow_enums, &self.arguments_rows, &mut self.pending_argument_editor_row, ctx, ); } } } fn handle_type_selector_event( &mut self, handle: ViewHandle, event: &WorkflowArgSelectorEvent, ctx: &mut ViewContext, ) { match event { WorkflowArgSelectorEvent::NewEnum => { // Find and store the row that emitted this event self.pending_argument_editor_row = self .arguments_rows .iter() .position(|row| row.typed_default_value_editor.eq(&handle)) .map(ArgumentEditorRowIndex); // Initialize the creation dialog for new enum self.enum_creation_dialog.update(ctx, |dialog, ctx| { dialog.initialize(ctx); }); self.show_enum_creation_dialog(ctx); } WorkflowArgSelectorEvent::LoadEnum(index) => { // Find and store the row that emitted this event self.pending_argument_editor_row = self .arguments_rows .iter() .position(|row| row.typed_default_value_editor.eq(&handle)) .map(ArgumentEditorRowIndex); // Load the enum data into the enum dialog let show_dialog = workflow_arg_type_helpers::load_enum( index, &self.all_workflow_enums, &self.enum_creation_dialog, ctx, ); if show_dialog { self.show_enum_creation_dialog(ctx); } } WorkflowArgSelectorEvent::Edited | WorkflowArgSelectorEvent::Close => ctx.notify(), WorkflowArgSelectorEvent::ToggleExpanded => { // Close all other rows self.arguments_rows.iter().for_each(|row| { if !row.typed_default_value_editor.eq(&handle) { row.typed_default_value_editor.update(ctx, |editor, ctx| { editor.close(ctx); }); } }); } WorkflowArgSelectorEvent::InputTab => { if let Some(index) = self .arguments_rows .iter() .position(|row| row.typed_default_value_editor.eq(&handle)) { match self.arguments_rows.get(index + 1) { Some(next_row) => ctx.focus(&next_row.description_editor), None => ctx.focus(&self.title_editor), } } } WorkflowArgSelectorEvent::InputShiftTab => { if let Some(row) = self .arguments_rows .iter() .find(|row| row.typed_default_value_editor.eq(&handle)) { ctx.focus(&row.description_editor); } } } } fn handle_argument_editor_event( &mut self, handle: ViewHandle, event: &EditorEvent, ctx: &mut ViewContext, ) { match event { // because the number of editor views we have depends on how many arguments // are in the content, tabbing/shift-tabbing is slightly complex. // `handle_argument_editor_event` is used for all of these views, so broadly // speaking there are two steps for each interaction: // 1. iterate through every row, looking for which editor fired this event // 2. decide what editor to focus next based on what editor's ahead/behind us EditorEvent::Navigate(NavigationKey::Tab) => { self.arguments_rows .iter() .enumerate() .for_each(|(index, row)| { // tabbing in a description editor just means we focus // the corresponding default value editor if row.description_editor.eq(&handle) { ctx.focus(&row.typed_default_value_editor); } else if row.default_value_editor.eq(&handle) { // if we have another row ahead of us, tabbing in the default // value editor moves to the following row's description editor. // otherwise, it wraps around to the title. match self.arguments_rows.get(index + 1) { Some(next_row) => ctx.focus(&next_row.description_editor), None => ctx.focus(&self.title_editor), } } }); } EditorEvent::Navigate(NavigationKey::ShiftTab) => { self.arguments_rows .iter() .enumerate() .for_each(|(index, row)| { // if we have another row behind us, shift-tabbing in the description // editor moves to the previous row's default value editor. // otherwise, it focuses the content editor. if row.description_editor.eq(&handle) { if index == 0 { ctx.focus(&self.content_editor); } else { ctx.focus( &self.arguments_rows[index - 1].typed_default_value_editor, ); } // shift-tabbing in a default value editor just means we // focus the corresponding default value editor } else if row.default_value_editor.eq(&handle) { ctx.focus(&row.description_editor); } }); } EditorEvent::Escape => self.close(false, ctx), EditorEvent::Edited(_) => ctx.notify(), _ => {} } } /// Updates our arguments rows state based on the current state of the ArgumentsState struct. /// There are two things that can happen: /// 1) The new struct reports **the same number** of arguments as we have rows. In this case, /// we match up the arguments and rows based on their positions in their respective arrays, /// updating the names of the rows. This lets a user change the name of an argument without /// losing their default value / description content. /// 2) The new struct reports either **more** or **fewer** arguments than we have rows. In this /// case, we toss out any rows that refer to arguments' names no longer in the arg state, /// and then add rows in between matches we DO find. This handles adding/removing args at /// any position in the content, preserving arg values that haven't changed. pub(super) fn update_arguments_rows(&mut self, ctx: &mut ViewContext) { let appearance = Appearance::as_ref(ctx); let ui_font_family = appearance.ui_font_family(); match self .arguments_rows .len() .cmp(&self.arguments_state.arguments.len()) { Ordering::Equal => { self.arguments_state .arguments .iter() .enumerate() .for_each(|(index, argument)| { self.arguments_rows[index].name.clone_from(&argument.name); }); } Ordering::Less | Ordering::Greater => { // first, get rid of all rows that have names not present in the updated args state let argument_names = self .arguments_state .arguments .iter() .map(|argument| argument.name.clone()) .collect::>(); self.arguments_rows .retain(|row| argument_names.contains(&row.name)); // next, go over each item in the args state, and either add a row at this position, // or skip over it if we've found a match self.arguments_state .arguments .iter() .enumerate() .for_each(|(index, argument)| { // if we reach the end of the state struct and we still // haven't inserted a row OR we find a mismatched name, // we know to add a row at this particular index if index == self.arguments_rows.len() || !argument.name.eq(&self.arguments_rows[index].name) { let description_editor = Self::create_editor_handle( ctx, Some(ARGUMENT_EDITOR_FONT_SIZE), Some(ui_font_family), Some(ARGUMENT_DESCRIPTION_PLACEHOLDER_TEXT), false, /* vim_keybindings */ false, ); ctx.subscribe_to_view( &description_editor, |me, emitter, event, ctx| { me.handle_argument_editor_event(emitter, event, ctx); }, ); let default_value_editor = Self::create_editor_handle( ctx, Some(ARGUMENT_EDITOR_FONT_SIZE), Some(ui_font_family), Some(ARGUMENT_DEFAULT_VALUE_PLACEHOLDER_TEXT), false, /* vim_keybindings */ false, ); ctx.subscribe_to_view( &default_value_editor, |me, emitter, event, ctx| { me.handle_argument_editor_event(emitter, event, ctx); }, ); let typed_default_value_editor = ctx.add_typed_action_view(|ctx| { WorkflowArgSelector::new( WorkflowArgSelectorStyles { editor_padding: Coords::uniform(ARGUMENT_INPUT_PADDING), width: Some(ARGUMENT_INPUT_WIDTH), height: None, dropdown_background: |appearance| { appearance.theme().background() }, border_color: |appearance| appearance.theme().outline(), border_radius: 0.0, }, &self.all_workflow_enums, ctx, ) }); ctx.subscribe_to_view( &typed_default_value_editor, |me, emitter, event, ctx| { me.handle_type_selector_event(emitter, event, ctx); }, ); self.arguments_rows.insert( index, ArgumentEditorRow { name: argument.name.clone(), description_editor, default_value_editor, typed_default_value_editor, }, ); } }); } } } fn clear_content_formatting(&mut self, num_chars_content: usize, ctx: &mut ViewContext) { self.content_editor.update(ctx, |editor, ctx| { editor.update_buffer_styles( vec![CharOffset::from(0)..CharOffset::from(num_chars_content)], TextStyleOperation::default().clear_error_underline_color(), ctx, ); editor.update_buffer_styles( vec![CharOffset::from(0)..CharOffset::from(num_chars_content)], TextStyleOperation::default().clear_foreground_color(), ctx, ); }); } fn apply_error_underlining_to_content(&mut self, ctx: &mut ViewContext) { let error_ranges = self.arguments_state.invalid_arguments_char_ranges.clone(); let appearance = Appearance::as_ref(ctx); let theme = appearance.theme(); let ansi_colors = theme.terminal_colors().normal; self.content_editor.update(ctx, |editor, ctx| { editor.update_buffer_styles( error_ranges .iter() .map(|range| CharOffset::from(range.start)..CharOffset::from(range.end)), TextStyleOperation::default().set_error_underline_color( AnsiColorIdentifier::Red.to_ansi_color(&ansi_colors).into(), ), ctx, ) }); } fn apply_argument_highlighting_to_content(&mut self, ctx: &mut ViewContext) { let argument_ranges = self .arguments_state .valid_arguments_char_ranges_and_arg_index .clone(); let appearance = Appearance::as_ref(ctx); let theme = appearance.theme(); let ansi_colors = theme.terminal_colors().normal; self.content_editor.update(ctx, |editor, ctx| { editor.update_buffer_styles( argument_ranges .iter() .map(|(range, _)| CharOffset::from(range.start)..CharOffset::from(range.end)), TextStyleOperation::default().set_foreground_color( AnsiColorIdentifier::Blue.to_ansi_color(&ansi_colors).into(), ), ctx, ) }); } fn is_new_argument_button_disabled(&self) -> bool { self.show_unsaved_changes_dialog || matches!( self.ai_metadata_assist_state, AiAssistState::RequestInFlight ) } fn is_save_workflow_button_disabled(&self) -> bool { self.show_unsaved_changes_dialog || self.errors.has_any_error() || matches!( self.ai_metadata_assist_state, AiAssistState::RequestInFlight ) || self.show_enum_creation_dialog } fn is_ai_assist_button_disabled(&self, app: &AppContext) -> bool { // Autofill button should be disabled when there is no content. self.content_editor.as_ref(app).is_empty(app) || self.show_unsaved_changes_dialog || matches!( self.ai_metadata_assist_state, AiAssistState::RequestInFlight ) } fn is_online(&self, app: &AppContext) -> bool { NetworkStatus::as_ref(app).is_online() } fn render_header_menu_and_close(&self, appearance: &Appearance) -> Box { let mut row = Flex::row(); // The menu should not appear when we are creating a workflow if self.workflow_id.is_some() { let overflow_menu = ConstrainedBox::new( icon_button_with_context_menu( Icon::DotsVertical, move |ctx, _, _| { ctx.dispatch_typed_action(WorkflowModalAction::OpenOverflowMenu); }, self.button_mouse_states.menu_state.clone(), &self.menu, self.menu_open, MenuDirection::Left, Some(Cursor::PointingHand), None, appearance, ) .finish(), ) .with_height(ICON_DIMENSIONS) .finish(); row.add_child(overflow_menu); } let close_button = icon_button( appearance, icons::Icon::X, false, /* active */ self.button_mouse_states.close_modal_state.clone(), ) .build() .on_click(|ctx, _, _| ctx.dispatch_typed_action(WorkflowModalAction::Close)) .finish(); row.add_child(close_button); row.finish() } fn render_header(&self, appearance: &Appearance) -> Box { let workflow_icon = Container::new( ConstrainedBox::new( Icon::from(DriveObjectType::Workflow) .to_galaxyui_icon( warp_drive_icon_color(appearance, DriveObjectType::Workflow).into(), ) .finish(), ) .with_width(ICON_DIMENSIONS) .with_height(ICON_DIMENSIONS) .finish(), ) .finish(); let workflow_title_description = Shrinkable::new( 1., Container::new( Flex::column() .with_child( Container::new(ChildView::new(&self.title_editor).finish()).finish(), ) .with_child( Container::new( ConstrainedBox::new(ChildView::new(&self.description_editor).finish()) .with_max_height(DESCRIPTION_EDITOR_MAX_HEIGHT) .finish(), ) .with_padding_top(DESCRIPTION_EDITOR_TOP_PADDING) .finish(), ) .finish(), ) .with_padding_left(MODAL_HORIZONTAL_PADDING) .with_padding_right(MODAL_HORIZONTAL_PADDING) .finish(), ) .finish(); // Case 1: Has breadcrumbs, so modal header = // first row = breadcrumbs on left side, overflow menu + close button on right side // second row = workflow icon + title/description if let Some(breadcrumbs) = &self.breadcrumbs { let rendered_breadcrumbs = breadcrumb::render_breadcrumbs( breadcrumbs.clone(), appearance, |ctx, _, object| { let item_id = match object.kind { ContainingObjectKind::Object(id) => WarpDriveItemId::Object(id), ContainingObjectKind::Space(space) => WarpDriveItemId::Space(space), }; ctx.dispatch_typed_action(WorkflowModalAction::ViewInWarpDrive(item_id)); }, ); Container::new( Flex::column() .with_child( Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_child(Shrinkable::new(1., rendered_breadcrumbs).finish()) .with_child(self.render_header_menu_and_close(appearance)) .finish(), ) .with_vertical_margin(BREADCRUMBS_VERTICAL_MARGIN) .finish(), ) .with_child( Flex::row() .with_child(workflow_icon) .with_child(workflow_title_description) .finish(), ) .finish(), ) .with_padding_left(MODAL_HORIZONTAL_PADDING) .with_padding_right(MODAL_HORIZONTAL_PADDING) .with_padding_top(MODAL_VERTICAL_PADDING) .with_padding_bottom(MODAL_VERTICAL_PADDING) .finish() } // Case 2: Creating a new workflow has no menu and breadcrumbs, so modal header = // workflow icon + title + close button on first row else { Container::new( Flex::row() .with_child(workflow_icon) .with_child(workflow_title_description) .with_child(self.render_header_menu_and_close(appearance)) .finish(), ) .with_padding_left(MODAL_HORIZONTAL_PADDING) .with_padding_right(MODAL_HORIZONTAL_PADDING) .with_padding_top(MODAL_VERTICAL_PADDING) .with_padding_bottom(MODAL_VERTICAL_PADDING) .finish() } } fn render_content_editor(&self, appearance: &Appearance, app: &AppContext) -> Box { let line_height = self .content_editor .as_ref(app) .line_height(app.font_cache(), appearance); Container::new( ConstrainedBox::new(ChildView::new(&self.content_editor).finish()) .with_min_height(COMMAND_EDITOR_MIN_LINES * line_height) .finish(), ) .with_uniform_padding(COMMAND_EDITOR_PADDING) .with_background(appearance.theme().background()) .finish() } fn render_arguments_editors(&self, appearance: &Appearance) -> Box { let children: Vec> = self .arguments_state .arguments .iter() .enumerate() .map(|(index, argument)| { let description_handle = &self.arguments_rows[index].description_editor; let text = appearance .ui_builder() .span(argument.clone().name) .with_style(UiComponentStyles { font_family_id: Some(appearance.monospace_font_family()), ..Default::default() }) .build() .finish(); let new_default_value_handle = &self.arguments_rows[index].typed_default_value_editor; Flex::row() .with_child(Shrinkable::new(1., Align::new(text).left().finish()).finish()) .with_child( Container::new( ConstrainedBox::new(ChildView::new(description_handle).finish()) .with_width(ARGUMENT_INPUT_WIDTH) .finish(), ) .with_background(appearance.theme().background()) .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) .with_uniform_padding(ARGUMENT_INPUT_PADDING) .with_margin_left(ARGUMENT_INPUT_MARGIN) .with_margin_right(ARGUMENT_INPUT_MARGIN) .finish(), ) .with_child( Container::new(ChildView::new(new_default_value_handle).finish()).finish(), ) .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish() }) .collect(); let theme = appearance.theme(); Container::new( ClippedScrollable::vertical( self.arguments_clipped_scroll_state.clone(), Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_children(children) .finish(), SCROLLBAR_WIDTH, theme.background().into(), theme.main_text_color(theme.background()).into(), theme.surface_1().into(), ) .finish(), ) .with_uniform_padding(COMMAND_EDITOR_PADDING) .finish() } fn render_footer(&self, appearance: &Appearance, app: &AppContext) -> Box { 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 hovered_and_clicked_styles = UiComponentStyles { background: Some(appearance.theme().surface_3().into()), ..default_button_styles }; let primary_button_styles = UiComponentStyles { background: Some(appearance.theme().accent_button_color().into()), border_color: Some(appearance.theme().accent_button_color().into()), font_color: Some( appearance .theme() .main_text_color(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 mut new_argument_button = appearance .ui_builder() .button( ButtonVariant::Secondary, self.button_mouse_states.new_argument_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(ARGUMENT_BUTTON_TEXT.into()); if self.is_new_argument_button_disabled() { new_argument_button = new_argument_button.disabled(); } let mut save_button = appearance .ui_builder() .button_with_custom_styles( ButtonVariant::Basic, self.button_mouse_states.save_workflow_state.clone(), primary_button_styles, Some(primary_hovered_and_clicked_styles), Some(primary_hovered_and_clicked_styles), Some(primary_disabled_styles), ) .with_text_label(SAVE_BUTTON_TEXT.into()); if self.is_save_workflow_button_disabled() { save_button = save_button.disabled(); } let render_save_button = save_button .build() .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| ctx.dispatch_typed_action(WorkflowModalAction::Save)) .finish(); let mut button_row = Flex::row() .with_child( Shrinkable::new( 1., new_argument_button .build() .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(WorkflowModalAction::AddArgument) }) .finish(), ) .finish(), ) .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); let label_and_icon = match self.ai_metadata_assist_state { AiAssistState::PreRequest => Some((AI_ASSIST_BUTTON_TEXT, Icon::AiAssistant)), AiAssistState::RequestInFlight => Some((AI_ASSIST_LOADING_TEXT, Icon::Refresh)), AiAssistState::Generated => None, }; if let Some((label, icon)) = label_and_icon { let text_and_icon = TextAndIcon::new( TextAndIconAlignment::TextFirst, label.to_string(), icon.to_galaxyui_icon(appearance.theme().active_ui_text_color()), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(16., 16.), ) .with_inner_padding(4.); let mut button = appearance .ui_builder() .button_with_custom_styles( ButtonVariant::Basic, self.button_mouse_states.ai_assist_state.clone(), default_button_styles.set_width(AI_ASSIST_BUTTON_SIZE), Some(hovered_and_clicked_styles.set_width(AI_ASSIST_BUTTON_SIZE)), Some(hovered_and_clicked_styles.set_width(AI_ASSIST_BUTTON_SIZE)), Some(primary_disabled_styles.set_width(AI_ASSIST_BUTTON_SIZE)), ) .with_text_and_icon_label(text_and_icon); if self.is_ai_assist_button_disabled(app) { button = button.disabled(); } let rendered_button = button .build() .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| ctx.dispatch_typed_action(WorkflowModalAction::AiAssist)) .finish(); let button_with_tool_tip = appearance.ui_builder().tool_tip_on_element( "Generate a title, descriptions, or parameters with Warp AI".to_string(), self.button_mouse_states.ai_assist_tool_tip.clone(), rendered_button, ParentAnchor::BottomMiddle, ChildAnchor::TopMiddle, vec2f(0., 5.), ); button_row.add_child( Flex::row() .with_child( Container::new(button_with_tool_tip) .with_margin_right(8.) .finish(), ) .with_child(render_save_button) .finish(), ) } else { button_row.add_child(render_save_button); } Container::new(button_row.finish()) .with_padding_left(MODAL_HORIZONTAL_PADDING) .with_padding_right(MODAL_HORIZONTAL_PADDING) .with_padding_top(MODAL_VERTICAL_PADDING) .with_padding_bottom(MODAL_VERTICAL_PADDING) .finish() } fn render_unsaved_changes_dialog(&self, appearance: &Appearance) -> Box { let keep_editing_button = appearance .ui_builder() .button( ButtonVariant::Secondary, self.button_mouse_states.keep_editing_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(KEEP_EDITING_TEXT.into()) .build() .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(WorkflowModalAction::CloseUnsavedChangesDialog) }) .finish(); let discard_changes_button = appearance .ui_builder() .button( ButtonVariant::Secondary, self.button_mouse_states.discard_changes_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(DISCARD_CHANGES_TEXT.into()) .build() .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| ctx.dispatch_typed_action(WorkflowModalAction::ForceClose)) .finish(); Container::new( Dialog::new( UNSAVED_CHANGES_TEXT.to_string(), None, dialog_styles(appearance), ) .with_bottom_row_child(keep_editing_button) .with_bottom_row_child(discard_changes_button) .with_width(DIALOG_WIDTH) .build() .finish(), ) .with_margin_left(MODAL_HORIZONTAL_MARGIN) .with_margin_right(MODAL_HORIZONTAL_MARGIN) .finish() } } impl Entity for WorkflowModal { type Event = WorkflowModalEvent; } impl View for WorkflowModal { fn ui_name() -> &'static str { "WorkflowModal" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let modal = Container::new( ConstrainedBox::new( Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(self.render_header(appearance)) .with_child( Shrinkable::new(1., self.render_content_editor(appearance, app)).finish(), ) .with_child( Shrinkable::new(1., self.render_arguments_editors(appearance)).finish(), ) .with_child(self.render_footer(appearance, app)) .finish(), ) .with_max_width(MODAL_WIDTH) .finish(), ) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(MODAL_BORDER_RADIUS))) .with_border(Border::all(BORDER_WIDTH).with_border_fill(theme.outline())) .with_background(theme.surface_1()) .with_margin_left(MODAL_HORIZONTAL_MARGIN) .with_margin_right(MODAL_HORIZONTAL_MARGIN) .with_margin_top(MODAL_VERTICAL_MARGIN) .with_margin_bottom(MODAL_VERTICAL_MARGIN) .finish(); let mut stack = Stack::new(); stack.add_positioned_child( modal, OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::Center, ChildAnchor::Center, ), ); if self.show_enum_creation_dialog { stack.add_positioned_overlay_child( Clipped::new(ChildView::new(&self.enum_creation_dialog).finish()).finish(), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::Center, ChildAnchor::Center, ), ) } if self.show_unsaved_changes_dialog { stack.add_positioned_overlay_child( self.render_unsaved_changes_dialog(appearance), 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()) .with_corner_radius(app.windows().window_corner_radius()) .finish() } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { ctx.focus(&self.title_editor); ctx.notify(); } } } impl TypedActionView for WorkflowModal { type Action = WorkflowModalAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { WorkflowModalAction::AddArgument => self.add_argument(ctx), WorkflowModalAction::Close => self.close(false, ctx), WorkflowModalAction::Save => self.save_workflow_and_close(ctx), WorkflowModalAction::CloseUnsavedChangesDialog => self.hide_unsaved_changes_dialog(ctx), WorkflowModalAction::ForceClose => { self.close(true, ctx); if let Some(id) = self.clicked_breadcrumb { self.view_in_warp_drive(id, ctx); } } WorkflowModalAction::AiAssist => self.issue_request(ctx), WorkflowModalAction::ViewInWarpDrive(id) => { if self.should_show_unsaved_changes_dialog(ctx) { self.clicked_breadcrumb = Some(*id); self.show_unsaved_changes_dialog(ctx); return; } self.view_in_warp_drive(*id, ctx) } WorkflowModalAction::OpenOverflowMenu => self.open_overflow_menu(ctx), WorkflowModalAction::CopyObjectToClipboard => self.copy_object_to_clipboard(ctx), WorkflowModalAction::TrashObject => self.trash_object(ctx), } } } #[cfg(test)] #[path = "modal_tests.rs"] mod tests;