use std::cell::RefCell; use std::collections::HashMap; use std::fmt::Display; use galaxy_core::features::FeatureFlag; use galaxyui::elements::{ Container, Flex, FormattedTextElement, HighlightedHyperlink, MouseStateHandle, ParentElement, }; use galaxyui::keymap::ContextPredicate; use galaxyui::presenter::ChildView; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::ui_components::switch::SwitchStateHandle; use galaxyui::{ Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use regex::Regex; use settings::{Setting, ToggleableSetting}; use strum::IntoEnumIterator; use super::settings_page::{ add_setting, render_alternating_color_list, render_body_item, render_dropdown_item, render_page_title, Category, LocalOnlyIconState, MatchData, PageType, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, ToggleState, HEADER_FONT_SIZE, HEADER_PADDING, }; use super::{flags, SettingsAction, SettingsSection, ToggleSettingActionPair}; use crate::appearance::Appearance; use crate::server::telemetry::TelemetryEvent; use crate::settings::{ReuseExistingSshControlMaster, SshSettings}; use crate::terminal::warpify::settings::{ EnableSshWarpification, SshExtensionInstallMode, SshExtensionInstallModeSetting, WarpifySettings, WarpifySettingsChangedEvent, }; use crate::ui_components::blended_colors; use crate::view_components::dropdown::{Dropdown, DropdownItem}; use crate::view_components::{SubmittableTextInput, SubmittableTextInputEvent}; use crate::{report_if_error, send_telemetry_from_ctx}; pub fn init_actions_from_parent_view( app: &mut AppContext, context: &ContextPredicate, builder: fn(SettingsAction) -> T, ) { // Add all of the toggle settings from the Warpify Page that you want to show up on the Command Palette here. let mut toggle_binding_pairs = vec![]; if WarpifySettings::as_ref(app) .enable_ssh_warpification .is_supported_on_current_platform() { toggle_binding_pairs.push(ToggleSettingActionPair::new( "SSH Warpification", builder(SettingsAction::WarpifyPageToggle( WarpifyPageAction::ToggleSshWarpification, )), context, flags::SSH_WARPIFICATION_CONTEXT_FLAG, )); } ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(toggle_binding_pairs, app); } const CONTENT_FONT_SIZE: f32 = 12.; const ITEM_VERTICAL_SPACING: f32 = 24.; /// There's a built-in 10px margin below the text input. const BUILT_IN_TEXT_INPUT_MARGIN: f32 = 10.; const SPACE_AFTER_TEXT_INPUT: f32 = ITEM_VERTICAL_SPACING - BUILT_IN_TEXT_INPUT_MARGIN; const SSH_REUSE_CONTROL_MASTER_DESCRIPTION: &str = "Attach to a live SSH ControlMaster you already have configured for the destination host instead of creating a Warp-owned one. Takes effect in new tabs."; const SSH_EXTENSION_INSTALL_MODE_DESCRIPTION: &str = "Controls the installation behavior for Galaxy's SSH extension when a remote host doesn't have it installed."; /// This page lets users configure when they get asked to warpify a session. Some shell commands /// are recognized by default. Users can add new shell commands, or prevent the default ones from /// asking. Users can also enable the SSH wrapper, and add hosts to a denylist. /// This page is essentially the View for the SubshellSettings model, as well as the SshSettings /// related to warpification. pub struct WarpifyPageView { page: PageType, /// This needs to mirror the length of SubshellSettings::added_remove_button_states. remove_added_command_button_states: Vec, add_added_commands_editor: ViewHandle, /// This needs to mirror the length of SubshellSettings::denylisted_remove_button_states. remove_denylisted_command_button_states: Vec, add_denylisted_commands_editor: ViewHandle, ssh_extension_install_mode_dropdown: ViewHandle>, } impl WarpifyPageView { pub fn new(ctx: &mut ViewContext) -> Self { let warpify_settings_handle = WarpifySettings::handle(ctx); ctx.observe(&warpify_settings_handle, Self::update_button_states); ctx.subscribe_to_model(&warpify_settings_handle, move |me, model, event, ctx| { me.update_button_states(model, ctx); if matches!( event, WarpifySettingsChangedEvent::SshExtensionInstallModeSetting { .. } ) { me.update_dropdown(ctx); } ctx.notify(); }); // Added commands can be specified by regex, while denied commands are strictly exact // match. let add_added_commands_editor = ctx.add_typed_action_view(|ctx| { let mut input = SubmittableTextInput::new(ctx).validate_on_edit(|regex| Regex::new(regex).is_ok()); input.set_placeholder_text("command (supports regex)", ctx); input }); ctx.subscribe_to_view( &add_added_commands_editor, Self::handle_added_command_editor_event, ); let add_denylisted_commands_editor = ctx.add_typed_action_view(|ctx| { let mut input = SubmittableTextInput::new(ctx); input.set_placeholder_text("command (supports regex)", ctx); input }); ctx.subscribe_to_view( &add_denylisted_commands_editor, Self::handle_denylisted_command_editor_event, ); let ssh_extension_install_mode_dropdown = Self::create_ssh_extension_install_mode_dropdown(ctx); let mut instance = Self { page: Self::build_page(ctx), remove_added_command_button_states: Default::default(), add_added_commands_editor, remove_denylisted_command_button_states: Default::default(), add_denylisted_commands_editor, ssh_extension_install_mode_dropdown, }; instance.update_button_states(warpify_settings_handle, ctx); instance } fn build_page(ctx: &mut ViewContext) -> PageType { let mut categories = vec![ Category::new("", vec![Box::new(TitleWidget::default())]), Category::new("Subshells", vec![Box::new(SubshellsWidget::default())]) .with_subtitle("Subshells supported: bash, zsh, and fish."), ]; let warpify_settings = WarpifySettings::as_ref(ctx); if warpify_settings .enable_ssh_warpification .is_supported_on_current_platform() { categories.push( Category::new("SSH", vec![Box::new(SSHWidget::default())]) .with_subtitle("Wormhole your interactive SSH sessions."), ); } PageType::new_categorized(categories, None) } /// This method ensures each command in the SubshellSettings has a matching button state for /// its delete button in the View. fn update_button_states( &mut self, warpify_settings_handle: ModelHandle, ctx: &mut ViewContext, ) { let warpify_settings = warpify_settings_handle.as_ref(ctx); self.remove_denylisted_command_button_states = warpify_settings .subshell_command_denylist .iter() .map(|_| Default::default()) .collect(); self.remove_added_command_button_states = warpify_settings .added_subshell_commands .iter() .map(|_| Default::default()) .collect(); ctx.notify(); } /// Syncs the install-mode dropdown selection with the current /// `WarpifySettings::ssh_extension_install_mode` value (e.g. after it /// was changed from the SSH remote server choice view). fn update_dropdown(&mut self, ctx: &mut ViewContext) { let current_mode = *WarpifySettings::as_ref(ctx) .ssh_extension_install_mode .value(); self.ssh_extension_install_mode_dropdown .update(ctx, |dropdown, ctx| { dropdown.set_selected_by_action( WarpifyPageAction::SetSshExtensionInstallMode(current_mode), ctx, ); }); } fn handle_added_command_editor_event( &mut self, _handle: ViewHandle, event: &SubmittableTextInputEvent, ctx: &mut ViewContext, ) { match event { SubmittableTextInputEvent::Submit(new_command) => { WarpifySettings::handle(ctx).update(ctx, |warpify_settings, ctx| { warpify_settings.add_subshell_command(new_command, ctx); }); send_telemetry_from_ctx!(TelemetryEvent::AddAddedSubshellCommand, ctx); } SubmittableTextInputEvent::Escape => ctx.emit(SettingsPageEvent::FocusModal), } } fn handle_denylisted_command_editor_event( &mut self, _handle: ViewHandle, event: &SubmittableTextInputEvent, ctx: &mut ViewContext, ) { match event { SubmittableTextInputEvent::Submit(new_command) => { WarpifySettings::handle(ctx).update(ctx, |warpify_settings, ctx| { warpify_settings.denylist_subshell_command(new_command, ctx); }); send_telemetry_from_ctx!(TelemetryEvent::AddDenylistedSubshellCommand, ctx); } SubmittableTextInputEvent::Escape => ctx.emit(SettingsPageEvent::FocusModal), } } fn remove_denylisted_command(&self, index: usize, ctx: &mut ViewContext) { send_telemetry_from_ctx!(TelemetryEvent::RemoveDenylistedSubshellCommand, ctx); WarpifySettings::handle(ctx).update(ctx, |warpify, ctx| { warpify.remove_denylisted_subshell_command(index, ctx) }); } fn remove_added_command(&self, index: usize, ctx: &mut ViewContext) { send_telemetry_from_ctx!(TelemetryEvent::RemoveAddedSubshellCommand, ctx); WarpifySettings::handle(ctx).update(ctx, |warpify, ctx| { warpify.remove_added_subshell_command(index, ctx) }); } } impl Entity for WarpifyPageView { type Event = SettingsPageEvent; } fn build_sub_sub_title(title: &str, appearance: &Appearance) -> Container { appearance .ui_builder() .span(title.to_string()) .with_style(UiComponentStyles { font_size: Some(CONTENT_FONT_SIZE), ..Default::default() }) .build() } const SSH_EXTENSION_DROPDOWN_WIDTH: f32 = 250.; impl WarpifyPageView { fn create_ssh_extension_install_mode_dropdown( ctx: &mut ViewContext, ) -> ViewHandle> { let items: Vec> = SshExtensionInstallMode::iter() .map(|mode| { DropdownItem::new( mode.display_name(), WarpifyPageAction::SetSshExtensionInstallMode(mode), ) }) .collect(); let current_mode = *WarpifySettings::as_ref(ctx) .ssh_extension_install_mode .value(); let enable_ssh_warpification = *WarpifySettings::as_ref(ctx) .enable_ssh_warpification .value(); ctx.add_typed_action_view(move |ctx| { let mut dropdown = Dropdown::new(ctx); dropdown.set_top_bar_max_width(SSH_EXTENSION_DROPDOWN_WIDTH); dropdown.set_menu_width(SSH_EXTENSION_DROPDOWN_WIDTH, ctx); dropdown.add_items(items, ctx); dropdown.set_selected_by_action( WarpifyPageAction::SetSshExtensionInstallMode(current_mode), ctx, ); if !enable_ssh_warpification { dropdown.set_disabled(ctx); } dropdown }) } /// Renders a title, a list of items that can be removed, and an input field to add new items. fn build_input_list< ListItem: Display, SettingsPageAction: Action + Clone, F: Fn(usize) -> SettingsPageAction, T: View, >( &self, title: &str, patterns: &[ListItem], mouse_states: &[MouseStateHandle], create_action: F, handle: &ViewHandle, appearance: &Appearance, ) -> Container { let mut column = Flex::column(); let mut title = build_sub_sub_title(title, appearance); if !patterns.is_empty() { title = title.with_padding_bottom(BUILT_IN_TEXT_INPUT_MARGIN); } column.add_child(title.finish()); render_alternating_color_list( &mut column, patterns, mouse_states, create_action, appearance, ); Container::new( column .with_child( Container::new(ChildView::new(handle).finish()) .with_margin_bottom(SPACE_AFTER_TEXT_INPUT) .finish(), ) .finish(), ) } } impl View for WarpifyPageView { fn ui_name() -> &'static str { "WarpifyPageView" } fn render(&self, app: &AppContext) -> Box { self.page.render(self, app) } } #[derive(Clone, Debug, PartialEq)] pub enum WarpifyPageAction { RemoveAddedCommand(usize), RemoveDenylistedCommand(usize), ToggleSshWarpification, /// Toggles whether the legacy SSH wrapper attaches to an existing /// ControlMaster for the destination host instead of creating its own. ToggleReuseSshControlMaster, /// Set the SSH extension installation mode (always ask / always install / always skip). SetSshExtensionInstallMode(SshExtensionInstallMode), OpenUrl(String), } impl TypedActionView for WarpifyPageView { type Action = WarpifyPageAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { use WarpifyPageAction::*; match action { RemoveDenylistedCommand(index) => self.remove_denylisted_command(*index, ctx), RemoveAddedCommand(index) => self.remove_added_command(*index, ctx), ToggleSshWarpification => { WarpifySettings::handle(ctx).update(ctx, |ssh_settings, ctx| { report_if_error!(ssh_settings .enable_ssh_warpification .toggle_and_save_value(ctx)); send_telemetry_from_ctx!( TelemetryEvent::ToggleSshWarpification { enabled: *ssh_settings.enable_ssh_warpification.value(), }, ctx ); }); let enabled = *WarpifySettings::as_ref(ctx) .enable_ssh_warpification .value(); self.ssh_extension_install_mode_dropdown .update(ctx, |dropdown, ctx| { if enabled { dropdown.set_enabled(ctx); } else { dropdown.set_disabled(ctx); } }); } ToggleReuseSshControlMaster => { SshSettings::handle(ctx).update(ctx, |ssh_settings, ctx| { report_if_error!(ssh_settings .reuse_existing_control_master .toggle_and_save_value(ctx)); send_telemetry_from_ctx!( TelemetryEvent::FeaturesPageAction { action: "ToggleSshReuseControlMaster".to_string(), value: ssh_settings .reuse_existing_control_master .value() .to_string(), }, ctx ); }); } SetSshExtensionInstallMode(mode) => { WarpifySettings::handle(ctx).update(ctx, |warpify_settings, ctx| { report_if_error!(warpify_settings .ssh_extension_install_mode .set_value(*mode, ctx)); send_telemetry_from_ctx!( TelemetryEvent::SetSshExtensionInstallMode { mode: mode.display_name(), }, ctx ); }); } OpenUrl(url) => { ctx.open_url(url.as_str()); } } } } impl SettingsPageMeta for WarpifyPageView { fn section() -> SettingsSection { SettingsSection::Warpify } fn should_render(&self, _ctx: &AppContext) -> bool { true } fn update_filter(&mut self, query: &str, ctx: &mut ViewContext) -> MatchData { self.page.update_filter(query, ctx) } fn scroll_to_widget(&mut self, widget_id: &'static str) { self.page.scroll_to_widget(widget_id) } fn clear_highlighted_widget(&mut self) { self.page.clear_highlighted_widget(); } } impl From> for SettingsPageViewHandle { fn from(view_handle: ViewHandle) -> Self { SettingsPageViewHandle::Warpify(view_handle) } } #[derive(Default)] struct TitleWidget { learn_more_highlight_index: HighlightedHyperlink, } impl TitleWidget { fn render_top_of_page(&self, appearance: &Appearance, _app: &AppContext) -> Box { let warpify_description = vec![ FormattedTextFragment::plain_text( "Configure whether Galaxy attempts to \u{201c}Wormhole\u{201d} (add support for blocks, \ input modes, etc) certain shells. ", ), FormattedTextFragment::hyperlink( "Learn more", "https://docs.warp.dev/terminal/warpify/subshells", ), ]; let warpify_description = FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(warpify_description)]), CONTENT_FONT_SIZE, appearance.ui_font_family(), appearance.ui_font_family(), blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()), self.learn_more_highlight_index.clone(), ) .with_hyperlink_font_color(appearance.theme().accent().into_solid()) .register_default_click_handlers(|url, _, ctx| { ctx.open_url(&url.url); }) .finish(); Flex::column() .with_child(render_page_title("Wormhole", HEADER_FONT_SIZE, appearance)) .with_child(warpify_description) .finish() } } impl SettingsWidget for TitleWidget { type View = WarpifyPageView; fn search_terms(&self) -> &str { "ssh subshell galaxify session" } fn render( &self, _view: &Self::View, appearance: &Appearance, app: &AppContext, ) -> Box { Container::new(self.render_top_of_page(appearance, app)) .with_margin_bottom(ITEM_VERTICAL_SPACING) .finish() } } #[derive(Default)] struct SubshellsWidget {} impl SubshellsWidget { fn render_subshells_section( &self, view: &WarpifyPageView, appearance: &Appearance, app: &AppContext, ) -> Box { let mut column = Flex::column(); let warpify_settings = WarpifySettings::as_ref(app); column.add_child( view.build_input_list( "Added commands", &warpify_settings.added_subshell_commands, &view.remove_added_command_button_states, WarpifyPageAction::RemoveAddedCommand, &view.add_added_commands_editor, appearance, ) .finish(), ); column.add_child( view.build_input_list( "Denylisted commands", &warpify_settings.subshell_command_denylist, &view.remove_denylisted_command_button_states, WarpifyPageAction::RemoveDenylistedCommand, &view.add_denylisted_commands_editor, appearance, ) .with_margin_bottom(-BUILT_IN_TEXT_INPUT_MARGIN) .finish(), ); column.finish() } } impl SettingsWidget for SubshellsWidget { type View = WarpifyPageView; fn search_terms(&self) -> &str { "galaxify subshell" } fn render( &self, view: &Self::View, appearance: &Appearance, app: &AppContext, ) -> Box { Container::new(self.render_subshells_section(view, appearance, app)) .with_margin_bottom(ITEM_VERTICAL_SPACING) .finish() } } #[derive(Default)] struct SSHWidget { enable_ssh_warpification_switch_state: SwitchStateHandle, reuse_control_master_switch_state: SwitchStateHandle, local_only_icon_tooltip_states: RefCell>, } impl SettingsWidget for SSHWidget { type View = WarpifyPageView; fn search_terms(&self) -> &str { "galaxify ssh" } fn render( &self, view: &Self::View, appearance: &Appearance, app: &AppContext, ) -> Box { let mut column = Flex::column(); let ui_builder = appearance.ui_builder(); let description_text_color = appearance .theme() .sub_text_color(appearance.theme().surface_2()); let enable_ssh_warpification = *WarpifySettings::as_ref(app) .enable_ssh_warpification .value(); add_setting( &mut column, &WarpifySettings::as_ref(app).enable_ssh_warpification, move || { render_body_item::( "Wormhole SSH Sessions".into(), None, LocalOnlyIconState::for_setting( EnableSshWarpification::storage_key(), EnableSshWarpification::sync_to_cloud(), &mut self.local_only_icon_tooltip_states.borrow_mut(), app, ), ToggleState::Enabled, appearance, ui_builder .switch(self.enable_ssh_warpification_switch_state.clone()) .check(enable_ssh_warpification) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(WarpifyPageAction::ToggleSshWarpification); }) .finish(), None, ) }, ); if FeatureFlag::SshRemoteServer.is_enabled() { let label_color_override = if !enable_ssh_warpification { Some(appearance.theme().disabled_ui_text_color()) } else { None }; add_setting( &mut column, &WarpifySettings::as_ref(app).ssh_extension_install_mode, move || { Container::new(render_dropdown_item( appearance, "Install SSH extension", Some(SSH_EXTENSION_INSTALL_MODE_DESCRIPTION), None, LocalOnlyIconState::for_setting( SshExtensionInstallModeSetting::storage_key(), SshExtensionInstallModeSetting::sync_to_cloud(), &mut self.local_only_icon_tooltip_states.borrow_mut(), app, ), label_color_override, &view.ssh_extension_install_mode_dropdown, )) .with_padding_bottom(HEADER_PADDING) .finish() }, ); } let reuse_existing_control_master = *SshSettings::as_ref(app) .reuse_existing_control_master .value(); add_setting( &mut column, &SshSettings::as_ref(app).reuse_existing_control_master, move || { let mut column = Flex::column(); column.add_child(render_body_item::( "Reuse existing SSH ControlMaster".into(), None, LocalOnlyIconState::for_setting( ReuseExistingSshControlMaster::storage_key(), ReuseExistingSshControlMaster::sync_to_cloud(), &mut self.local_only_icon_tooltip_states.borrow_mut(), app, ), enable_ssh_warpification.into(), appearance, ui_builder .switch(self.reuse_control_master_switch_state.clone()) .check(reuse_existing_control_master) .with_disabled(!enable_ssh_warpification) .build() .on_click(move |ctx, _, _| { if !enable_ssh_warpification { return; } ctx.dispatch_typed_action( WarpifyPageAction::ToggleReuseSshControlMaster, ); }) .finish(), None, )); column.add_child( ui_builder .paragraph(SSH_REUSE_CONTROL_MASTER_DESCRIPTION.to_owned()) .with_style(UiComponentStyles { font_color: Some(description_text_color.into_solid()), margin: Some( Coords::default() .top(styles::DESCRIPTION_NEGATIVE_MARGIN_OFFSET) .bottom(styles::DESCRIPTION_LINE_MARGIN_BOTTOM), ), ..Default::default() }) .build() .finish(), ); column.finish() }, ); column.finish() } } mod styles { // Apply a negative margin to the description text so it appears closer to the main // settings option text. pub const DESCRIPTION_NEGATIVE_MARGIN_OFFSET: f32 = -8.; /// The space after a description. pub const DESCRIPTION_LINE_MARGIN_BOTTOM: f32 = 18.; }