Files
galaxy/app/src/settings_view/warpify_page.rs
T

749 lines
27 KiB
Rust

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<T: Action + Clone>(
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<Self>,
/// This needs to mirror the length of SubshellSettings::added_remove_button_states.
remove_added_command_button_states: Vec<MouseStateHandle>,
add_added_commands_editor: ViewHandle<SubmittableTextInput>,
/// This needs to mirror the length of SubshellSettings::denylisted_remove_button_states.
remove_denylisted_command_button_states: Vec<MouseStateHandle>,
add_denylisted_commands_editor: ViewHandle<SubmittableTextInput>,
ssh_extension_install_mode_dropdown: ViewHandle<Dropdown<WarpifyPageAction>>,
}
impl WarpifyPageView {
pub fn new(ctx: &mut ViewContext<Self>) -> 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<Self>) -> PageType<Self> {
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<WarpifySettings>,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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<SubmittableTextInput>,
event: &SubmittableTextInputEvent,
ctx: &mut ViewContext<Self>,
) {
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<SubmittableTextInput>,
event: &SubmittableTextInputEvent,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>,
) -> ViewHandle<Dropdown<WarpifyPageAction>> {
let items: Vec<DropdownItem<WarpifyPageAction>> = 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<T>,
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<dyn Element> {
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<Self>) {
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<Self>) -> 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<ViewHandle<WarpifyPageView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<WarpifyPageView>) -> 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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<HashMap<String, MouseStateHandle>>,
}
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<dyn Element> {
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::<WarpifyPageAction>(
"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::<WarpifyPageAction>(
"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.;
}