use crate::{ ai::agent::icons::{yellow_running_icon, yellow_stop_icon}, view_components::compactible_action_button::{ CompactibleActionButton, RenderCompactibleActionButton, SMALL_SIZE_SWITCH_THRESHOLD, }, }; use lazy_static::lazy_static; use parking_lot::RwLock; use settings::Setting as _; use std::borrow::Cow; use std::rc::Rc; use std::sync::Arc; use warp_core::semantic_selection::SemanticSelection; use warp_core::{features::FeatureFlag, ui::Icon}; use warpui::{ elements::{ get_rich_content_position_id, Border, Clipped, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, MouseStateHandle, ParentElement, Radius, SavePosition, SelectableArea, SelectionHandle, }, keymap::{FixedBinding, Keystroke}, AppContext, Element, Entity, EntityId, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, }; use crate::{ ai::blocklist::block::view_impl::{CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN}, ai::blocklist::inline_action::inline_action_header::INLINE_ACTION_HORIZONTAL_PADDING, ai::blocklist::inline_action::inline_action_header::{ ExpandedConfig, HeaderConfig, InteractionMode, }, ai::blocklist::inline_action::inline_action_icons::{self}, appearance::Appearance, settings::InputModeSettings, terminal::{ block_list_element::BlockListMenuSource, block_list_viewport::InputMode, view::TerminalAction, }, ui_components::blended_colors, view_components::action_button::{ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme}, }; /// The vertical padding applied to the env var collection block's content body. /// For horizontal padding, use [`INLINE_ACTION_HORIZONTAL_PADDING`] for consistency. const ENV_VAR_COLLECTION_BODY_VERTICAL_PADDING: f32 = 16.; const ENV_VAR_COLLECTION_CANCEL_LABEL: &str = "Cancel"; const ENV_VAR_COLLECTION_ACCEPT_LABEL: &str = "Run"; lazy_static! { static ref CANCEL_ENV_VAR_COLLECTION_KEYSTROKE: Keystroke = Keystroke { ctrl: true, key: "c".to_owned(), ..Default::default() }; static ref ACCEPT_ENV_VAR_COLLECTION_KEYSTROKE: Keystroke = Keystroke { key: "enter".to_owned(), ..Default::default() }; } #[derive(Debug, Clone)] pub enum EnvVarCollectionBlockEvent { Cancelled, RanCommand(String), ToggledExpanded(String), TextSelected, } #[derive(Debug, Clone)] pub enum EnvVarCollectionBlockAction { Cancel, RunCommand, /// Only applies to text selections made at the `EnvVarCollectionBlock` level. Child views of the /// `EnvVarCollectionBlock` are responsible for managing their own text selection states. SelectText, ToggleExpanded, } #[derive(Debug, Clone, PartialEq, Eq)] enum EnvVarCollectionState { /// The env var command is loaded and waiting to be run by the user. WaitingForUser, /// The env var command is currently running, after being accepted by the user. Running, /// The env var command finished running and succeeded. Succeeded, /// The env var command finished running and failed. Failed, /// The env var command was cancelled at some point before completing /// (i.e. before [`Self::Succeeded`] or [`Self::Failed`]). Cancelled, } impl EnvVarCollectionState { fn has_completed(&self) -> bool { matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled) } } pub struct EnvVarCollectionBlock { cancel_button: CompactibleActionButton, accept_button: CompactibleActionButton, command: String, command_output: Option, state: EnvVarCollectionState, block_id: String, view_id: EntityId, /// The output grid needs to be selectable to allow users to copy the command to their clipboard. /// Only applies to text selections made at the `EnvVarCollectionBlock` level. Child views of the /// `EnvVarCollectionBlock` are responsible for managing their own text selection states. selection_handle: SelectionHandle, selected_text: Arc>>, header_is_expanded: bool, header_mouse_state: MouseStateHandle, } pub fn init(app: &mut AppContext) { use warpui::keymap::macros::*; app.register_fixed_bindings([ FixedBinding::new( "ctrl-c", EnvVarCollectionBlockAction::Cancel, id!(EnvVarCollectionBlock::ui_name()), ), FixedBinding::new( "enter", EnvVarCollectionBlockAction::RunCommand, id!(EnvVarCollectionBlock::ui_name()), ), FixedBinding::new( "numpadenter", EnvVarCollectionBlockAction::RunCommand, id!(EnvVarCollectionBlock::ui_name()), ), ]); } impl EnvVarCollectionBlock { pub fn new( block_id: String, _collection_title: String, command: String, ctx: &mut ViewContext, ) -> Self { let cancel_button = CompactibleActionButton::new( ENV_VAR_COLLECTION_CANCEL_LABEL.to_string(), Some(KeystrokeSource::Fixed( CANCEL_ENV_VAR_COLLECTION_KEYSTROKE.clone(), )), ButtonSize::InlineActionHeader, EnvVarCollectionBlockAction::Cancel, Icon::X, Arc::new(NakedTheme), ctx, ); let accept_button = CompactibleActionButton::new( ENV_VAR_COLLECTION_ACCEPT_LABEL.to_string(), Some(KeystrokeSource::Fixed( ACCEPT_ENV_VAR_COLLECTION_KEYSTROKE.clone(), )), ButtonSize::InlineActionHeader, EnvVarCollectionBlockAction::RunCommand, Icon::Check, Arc::new(PrimaryTheme), ctx, ); Self { cancel_button, accept_button, command, command_output: None, state: EnvVarCollectionState::WaitingForUser, block_id, view_id: ctx.view_id(), selection_handle: Default::default(), selected_text: Default::default(), header_is_expanded: false, header_mouse_state: Default::default(), } } fn handle_toggle_expanded(&mut self, ctx: &mut ViewContext) { self.header_is_expanded = !self.header_is_expanded; ctx.emit(EnvVarCollectionBlockEvent::ToggledExpanded( self.block_id.clone(), )); ctx.notify(); } pub fn focus(&self, ctx: &mut ViewContext) { ctx.focus_self(); } pub fn get_block_id(&self) -> &String { &self.block_id } pub fn is_block_completed(&self) -> bool { self.state.has_completed() } pub fn is_running(&self) -> bool { self.state == EnvVarCollectionState::Running } pub fn on_succeeded(&mut self, ctx: &mut ViewContext) { self.state = EnvVarCollectionState::Succeeded; ctx.notify(); } pub fn on_failed(&mut self, output: Option, ctx: &mut ViewContext) { self.command_output = output; self.state = EnvVarCollectionState::Failed; ctx.notify(); } fn run_command(&mut self, ctx: &mut ViewContext) { if !self.is_block_completed() { self.state = EnvVarCollectionState::Running; ctx.emit(EnvVarCollectionBlockEvent::RanCommand(self.command.clone())); ctx.notify(); } } pub fn cancel(&mut self, ctx: &mut ViewContext) { if !self.is_block_completed() { self.state = EnvVarCollectionState::Cancelled; ctx.emit(EnvVarCollectionBlockEvent::Cancelled); ctx.notify(); } } /// Returns the currently selected text within the entire `EnvVarCollectionBlock` view sub-hierarchy. /// There **shouldn't** be more than one instance of selected text at any given time across /// any view within the same `EnvVarCollectionBlock` view sub-hierarchy. pub fn selected_text(&self, _ctx: &AppContext) -> Option { self.selected_text.read().clone() } pub fn clear_selection(&mut self, ctx: &mut ViewContext) { self.selection_handle.clear(); *self.selected_text.write() = None; ctx.notify(); } pub fn handle_ctrl_c(&mut self, ctx: &mut ViewContext) { self.cancel(ctx); } fn render_header(&self, app: &AppContext) -> Box { const COMMAND_WAITING_FOR_USER_MESSAGE: &str = "OK if I run this command and read the output?"; let title: Cow<'static, str> = if self.state == EnvVarCollectionState::WaitingForUser { COMMAND_WAITING_FOR_USER_MESSAGE.into() } else { self.command.clone().into() }; let appearance = Appearance::as_ref(app); let icon = match self.state { EnvVarCollectionState::WaitingForUser => Some(yellow_stop_icon(appearance)), EnvVarCollectionState::Running => Some(yellow_running_icon(appearance)), EnvVarCollectionState::Succeeded => { Some(inline_action_icons::green_check_icon(appearance)) } EnvVarCollectionState::Failed => Some(inline_action_icons::red_x_icon(appearance)), EnvVarCollectionState::Cancelled => { Some(inline_action_icons::cancelled_icon(appearance)) } }; let interaction_mode = match self.state { EnvVarCollectionState::WaitingForUser => { let buttons: Vec> = vec![ Rc::new(self.cancel_button.clone()), Rc::new(self.accept_button.clone()), ]; Some(InteractionMode::ActionButtons { action_buttons: buttons, size_switch_threshold: SMALL_SIZE_SWITCH_THRESHOLD, }) } EnvVarCollectionState::Failed => { let expansion_config = ExpandedConfig::new(self.header_is_expanded, self.header_mouse_state.clone()) .with_toggle_callback(move |ctx| { ctx.dispatch_typed_action(EnvVarCollectionBlockAction::ToggleExpanded); }); Some(InteractionMode::ManuallyExpandable(expansion_config)) } _ => None, }; let mut config = HeaderConfig::new(title, app).with_selectable_text(); if let Some(icon) = icon { config = config.with_icon(icon); } if let Some(mode) = interaction_mode { config = config.with_interaction_mode(mode); } config.render(app) } } impl Entity for EnvVarCollectionBlock { type Event = EnvVarCollectionBlockEvent; } impl View for EnvVarCollectionBlock { fn ui_name() -> &'static str { "EnvVarCollectionBlock" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); // Build the stateless header based on current state let header_element = self.render_header(app); let mut content = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(Clipped::new(header_element).finish()); let is_header_expanded = self.state == EnvVarCollectionState::WaitingForUser || self.header_is_expanded; let is_input_pinned_to_top = *InputModeSettings::as_ref(app).input_mode.value() == InputMode::PinnedToTop; // If we're expanding the env var collection block downward, we want the "Viewing command // detail" row to look connected to the block rendered below, which affects styling. Note // that `EnvVarCollectionState::Failed` is the only state that permits expansion toggling. let should_expand_downward = is_header_expanded && !is_input_pinned_to_top && self.state == EnvVarCollectionState::Failed; if is_header_expanded && self.state == EnvVarCollectionState::WaitingForUser { let selectable_child = Container::new( FormattedTextElement::from_str( self.command.clone(), appearance.monospace_font_family(), appearance.monospace_font_size(), ) .with_color(blended_colors::text_main(theme, theme.background())) .with_line_height_ratio(1.3) .set_selectable(true) .finish(), ) .with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING) .with_vertical_padding(ENV_VAR_COLLECTION_BODY_VERTICAL_PADDING) .with_background(theme.background()) .with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.))) .finish(); let semantic_selection = SemanticSelection::as_ref(app); let selected_text = self.selected_text.clone(); let view_id = self.view_id; let mut selectable = SelectableArea::new( self.selection_handle.clone(), move |selection_args, _, _| { *selected_text.write() = selection_args.selection; }, SavePosition::new( selectable_child, get_rich_content_position_id(&view_id).as_str(), ) .finish(), ) .with_word_boundaries_policy(semantic_selection.word_boundary_policy()) .with_smart_select_fn(semantic_selection.smart_select_fn()) .on_selection_updated(|ctx, _| { ctx.dispatch_typed_action(EnvVarCollectionBlockAction::SelectText) }) .on_selection_right_click(move |ctx, position| { ctx.dispatch_typed_action(TerminalAction::BlockListContextMenu( BlockListMenuSource::RichContentTextRightClick { rich_content_view_id: view_id, position_in_rich_content: position, }, )) }); if FeatureFlag::RectSelection.is_enabled() { selectable = selectable.should_support_rect_select(); } content.add_child(selectable.finish()); } let border_color = if self.state == EnvVarCollectionState::WaitingForUser { theme.accent() } else { theme.surface_2() }; let content = Container::new(content.finish()) // Since expanded details are rendered using a regular block, having a non-zero horizontal // margin while toggled expanded will cause the body to look wider than the header. .with_horizontal_margin(if should_expand_downward { 0. } else { CONTENT_HORIZONTAL_PADDING }) // Since expanded details are rendered using a regular block, having a non-zero bottom // margin while toggled expanded will cause the body to look disconnected from the header. .with_margin_bottom(if should_expand_downward { 0. } else { CONTENT_ITEM_VERTICAL_MARGIN }) // Rounded corners will make the header feel disconnected from its expanded details. .with_corner_radius(if should_expand_downward { CornerRadius::with_top(Radius::Pixels(9.)) } else { CornerRadius::with_all(Radius::Pixels(9.)) }) .with_border(Border::all(1.).with_border_fill(border_color)) .finish(); Container::new(content) .with_padding_top(CONTENT_ITEM_VERTICAL_MARGIN) .with_background(theme.ai_blocks_overlay()) .with_border(Border::top(1.).with_border_fill(theme.outline())) .finish() } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { ctx.focus_self(); ctx.notify(); } } } impl TypedActionView for EnvVarCollectionBlock { type Action = EnvVarCollectionBlockAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { EnvVarCollectionBlockAction::RunCommand => self.run_command(ctx), EnvVarCollectionBlockAction::Cancel => self.cancel(ctx), EnvVarCollectionBlockAction::SelectText => { ctx.emit(EnvVarCollectionBlockEvent::TextSelected) } EnvVarCollectionBlockAction::ToggleExpanded => self.handle_toggle_expanded(ctx), } } }