feat: expand Galaxy agent and remote tooling
Add Wormhole remote helpers, provider and agent improvements, filesystem diagnostics, model metadata support, and schema-aware settings IntelliSense.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
pub mod render;
|
||||
pub mod settings;
|
||||
pub mod success_block;
|
||||
pub mod trigger_state;
|
||||
|
||||
use channel_versions::overrides::TargetOS;
|
||||
use warpui::AssetProvider;
|
||||
|
||||
use crate::terminal::model::terminal_model::SubshellInitializationInfo;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::ASSETS;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum SubshellSource {
|
||||
Command(String),
|
||||
EnvVarCollection(String),
|
||||
}
|
||||
|
||||
/// This template is for the snippet that appears in the output grid for the success block if the
|
||||
/// subshell is local.
|
||||
fn get_subshell_bootstrap_success_block_path(shell_type: ShellType) -> Option<&'static str> {
|
||||
match shell_type {
|
||||
ShellType::Bash | ShellType::Zsh => {
|
||||
Some("bundled/bootstrap/bash_zsh_subshell_bootstrap_block_output.txt")
|
||||
}
|
||||
ShellType::Fish => Some("bundled/bootstrap/fish_subshell_bootstrap_block_output.txt"),
|
||||
ShellType::PowerShell => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns OutputGrid bytes to be rendered in the hardcoded "Wormholed subshell" block that's added
|
||||
/// to the blocklist upon successful subshell bootstrap.
|
||||
///
|
||||
/// The exact block contents varies based on whether or not the session is local or remote, in
|
||||
/// addition to the given `shell_type`.
|
||||
pub fn subshell_bootstrap_success_block_bytes(
|
||||
subshell_initialization_info: &SubshellInitializationInfo,
|
||||
shell_type: ShellType,
|
||||
os: TargetOS,
|
||||
) -> (Vec<u8>, bool) {
|
||||
let from_env_var_collection = subshell_initialization_info
|
||||
.env_var_collection_name
|
||||
.is_some();
|
||||
|
||||
if from_env_var_collection {
|
||||
return (vec![], false);
|
||||
}
|
||||
|
||||
let Some(subshell_bootstrap_success_block_path) =
|
||||
get_subshell_bootstrap_success_block_path(shell_type)
|
||||
else {
|
||||
return ("".into(), false);
|
||||
};
|
||||
|
||||
let templated_subshell_bootstrap_success_block_output_bytes = ASSETS
|
||||
.get(subshell_bootstrap_success_block_path)
|
||||
.unwrap_or_else(|_| {
|
||||
panic!("Failed to retrieve {subshell_bootstrap_success_block_path} from assets.")
|
||||
})
|
||||
.to_vec();
|
||||
|
||||
let rc_file_paths = shell_type.rc_file_paths(os);
|
||||
let mut is_executable = true;
|
||||
let commands: Vec<Vec<u8>> = rc_file_paths
|
||||
.iter()
|
||||
.map(|rc_file_path| {
|
||||
let rc_file_path = rc_file_path.to_str();
|
||||
is_executable &= rc_file_path.is_some();
|
||||
replace_template_chars_with_arguments(
|
||||
templated_subshell_bootstrap_success_block_output_bytes
|
||||
.trim_ascii_end()
|
||||
.to_owned()
|
||||
.to_vec(),
|
||||
vec![
|
||||
shell_type.name().to_owned(),
|
||||
rc_file_path.unwrap_or("<Your RC file>").to_owned(),
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(commands.concat(), is_executable)
|
||||
}
|
||||
|
||||
/// Replaces each instance of '%' in the given `templated_bytes` vector with `String` in
|
||||
/// `arguments`, in order.
|
||||
///
|
||||
/// The bundled block content txt files are templated using '%' as a placeholder to be dynamically
|
||||
/// replaced at runtime. This is useful to cater the exact block contents to the bootstrapped
|
||||
/// subshell.
|
||||
fn replace_template_chars_with_arguments(
|
||||
mut templated_bytes: Vec<u8>,
|
||||
arguments: Vec<String>,
|
||||
) -> Vec<u8> {
|
||||
// This was an arbitrarily chosen character.
|
||||
const TEMPLATE_CHAR: u8 = b'%';
|
||||
|
||||
for argument in arguments {
|
||||
let template_i = templated_bytes.iter().position(|b| b == &TEMPLATE_CHAR);
|
||||
if let Some(template_i) = template_i {
|
||||
templated_bytes.splice(template_i..template_i + 1, argument.into_bytes());
|
||||
} else {
|
||||
debug_assert!(false, "Number of arguments does not match number of template chars (%) in hardcoded subshell block bytes.");
|
||||
}
|
||||
}
|
||||
templated_bytes
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
|
||||
use galaxyui::elements::{
|
||||
Align, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement,
|
||||
HighlightedHyperlink, Icon, MouseStateHandle, ParentElement, Radius, Rect, Shrinkable, Stack,
|
||||
Text,
|
||||
};
|
||||
use galaxyui::fonts::{FamilyId, Properties, Weight};
|
||||
use galaxyui::ui_components::components::{UiComponent as _, UiComponentStyles};
|
||||
use galaxyui::{AppContext, Element, EventContext, PaintContext, SingletonEntity as _};
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
use super::settings::WormholeSettings;
|
||||
use super::SubshellSource;
|
||||
use crate::ai::blocklist::inline_action::inline_action_icons;
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
/// The flag font size varies with the monospace font width, but if it gets too big it will start
|
||||
/// to overlap with the prompt grid. This should eventually be fixed by growing the block height to
|
||||
/// fit the flag, but for now we can limit the flag font size to this maximum value.
|
||||
pub const MAXIMUM_FLAG_FONT_SIZE: f32 = 13.;
|
||||
|
||||
const SUBSHELL_FLAG_HORIZONTAL_PADDING: f32 = 8.;
|
||||
const SUBSHELL_FLAG_VERTICAL_PADDING: f32 = 1.;
|
||||
|
||||
// TODO(liam): remove this once figuring out how to get theme color in layout()
|
||||
const WARP_DRIVE_ENV_VAR_COLLECTION_ICON_COLOR: u32 = 0xC464FFFF;
|
||||
const ICON_MARGIN: f32 = 4.;
|
||||
const TERMINAL_ICON: &str = "bundled/svg/terminal.svg";
|
||||
pub const HORIZONTAL_TEXT_MARGIN: f32 = 20.;
|
||||
|
||||
/// Errored blocks have a red stripe, and subshells have a gray one.
|
||||
pub const LEFT_STRIPE_WIDTH: f32 = 5.;
|
||||
|
||||
pub fn build_header_row(
|
||||
text: &'static str,
|
||||
icon: Icon,
|
||||
theme: &GalaxyTheme,
|
||||
appearance: &Appearance,
|
||||
) -> Container {
|
||||
let mut row = Flex::row();
|
||||
row.add_child(
|
||||
ConstrainedBox::new(icon.finish())
|
||||
.with_height(appearance.monospace_font_size() + 2.)
|
||||
.with_width(appearance.monospace_font_size() + 2.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
row.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
text,
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Container::new(row.finish())
|
||||
}
|
||||
|
||||
pub fn apply_spacing_styles(header_row: Container) -> Container {
|
||||
header_row
|
||||
.with_horizontal_margin(HORIZONTAL_TEXT_MARGIN)
|
||||
.with_margin_top(8.)
|
||||
}
|
||||
|
||||
/// UI helper to render the header of an SSH rich content block.
|
||||
pub fn header_row(
|
||||
text: &'static str,
|
||||
icon: Icon,
|
||||
theme: &GalaxyTheme,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
apply_spacing_styles(build_header_row(text, icon, theme, appearance)).finish()
|
||||
}
|
||||
|
||||
fn green_check_icon(appearance: &Appearance, size: f32) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(inline_action_icons::green_check_icon(appearance).finish())
|
||||
.with_max_height(size)
|
||||
.with_max_width(size)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// UI helper to render the ssh command that caused the wormholing prompt.
|
||||
pub fn build_command_row(
|
||||
command: String,
|
||||
theme: &GalaxyTheme,
|
||||
appearance: &Appearance,
|
||||
show_green_check: bool,
|
||||
) -> Container {
|
||||
let text = FormattedTextElement::from_str(
|
||||
command,
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(blended_colors::text_main(theme, theme.background()))
|
||||
.finish();
|
||||
|
||||
let icon_size = appearance.monospace_font_size() + 2.;
|
||||
let icon = Container::new(green_check_icon(appearance, icon_size))
|
||||
.with_margin_right(icon_size)
|
||||
.finish();
|
||||
|
||||
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
if show_green_check {
|
||||
row.add_child(icon);
|
||||
}
|
||||
row.add_child(Shrinkable::new(1., text).finish());
|
||||
|
||||
Container::new(row.finish())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_background_color(theme.background().into_solid())
|
||||
.with_vertical_padding(12.)
|
||||
.with_horizontal_padding(16.)
|
||||
.with_horizontal_margin(HORIZONTAL_TEXT_MARGIN)
|
||||
.with_margin_top(16.)
|
||||
}
|
||||
|
||||
/// UI helper to render the description row of an SSH rich content block.
|
||||
pub fn build_description_row(
|
||||
text: FormattedText,
|
||||
theme: &GalaxyTheme,
|
||||
appearance: &Appearance,
|
||||
highlight_index: HighlightedHyperlink,
|
||||
) -> FormattedTextElement {
|
||||
let font_size = appearance.monospace_font_size();
|
||||
let font_family = appearance.monospace_font_family();
|
||||
let code_font_family = appearance.monospace_font_family();
|
||||
let font_color = blended_colors::text_sub(theme, theme.background());
|
||||
|
||||
FormattedTextElement::new(
|
||||
text,
|
||||
font_size,
|
||||
font_family,
|
||||
code_font_family,
|
||||
font_color,
|
||||
highlight_index.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn description_row(
|
||||
text: &str,
|
||||
theme: &GalaxyTheme,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let text = FormattedText::new(vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(text),
|
||||
])]);
|
||||
|
||||
apply_spacing_styles(Container::new(
|
||||
build_description_row(text, theme, appearance, Default::default()).finish(),
|
||||
))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a "Never Wormhole this host" link or nothing.
|
||||
pub fn render_never_wormhole_ssh_link(
|
||||
ssh_host: &Option<String>,
|
||||
app: &AppContext,
|
||||
appearance: &Appearance,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
on_never_wormhole: fn(&mut EventContext<'_>, ssh_host: String),
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let Some(ssh_host) = ssh_host else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let settings = WormholeSettings::handle(app);
|
||||
if settings.as_ref(app).is_ssh_host_denylisted(ssh_host) {
|
||||
// Should only happen if user manually attempts to Wormhole a denylisted host.
|
||||
return None;
|
||||
}
|
||||
|
||||
let link = appearance
|
||||
.ui_builder()
|
||||
.link(
|
||||
"Never Wormhole this host".into(),
|
||||
None,
|
||||
Some(Box::new({
|
||||
let ssh_host = ssh_host.clone();
|
||||
move |ctx| on_never_wormhole(ctx, ssh_host.to_owned())
|
||||
})),
|
||||
mouse_state_handle,
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
Some(Align::new(link).bottom_right().finish())
|
||||
}
|
||||
|
||||
fn get_subshell_flag_info(subshell_source: &SubshellSource, theme: &GalaxyTheme) -> (String, Fill) {
|
||||
match subshell_source {
|
||||
SubshellSource::EnvVarCollection(environment_name) => (
|
||||
environment_name.to_string(),
|
||||
Fill::Solid(ColorU::from_u32(WARP_DRIVE_ENV_VAR_COLLECTION_ICON_COLOR)),
|
||||
),
|
||||
SubshellSource::Command(command) => (command.to_string(), theme.subshell_background()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A single solid color vertical bar positioned on the left-hand side of a blocklist element
|
||||
/// or the TextInput area, used to indicate being inside a context (like a subshell).
|
||||
/// Implementation should match `[render_subshell_flag_pole]`.
|
||||
pub fn draw_flag_pole(
|
||||
origin: Vector2F,
|
||||
height: f32,
|
||||
fill: impl Into<Fill>,
|
||||
ctx: &mut PaintContext,
|
||||
) {
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(origin, Vector2F::new(LEFT_STRIPE_WIDTH, height)))
|
||||
.with_background(fill.into());
|
||||
}
|
||||
|
||||
/// A single solid color vertical bar positioned on the left-hand side of a blocklist element
|
||||
/// or the TextInput area, used to indicate being inside a context (like a subshell).
|
||||
/// Implementation should match `[draw_subshell_flag_pole]`.
|
||||
pub fn render_subshell_flag_pole(
|
||||
max_height: f32,
|
||||
fill: impl Into<galaxyui::elements::Fill>,
|
||||
) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(Rect::new().with_background(fill.into()).finish())
|
||||
.with_width(LEFT_STRIPE_WIDTH)
|
||||
.with_height(max_height)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// This function creates the Element for the subshell flag, which may be needed by the block list
|
||||
/// and the input editor.
|
||||
pub fn render_subshell_flag(
|
||||
subshell_source: SubshellSource,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
theme: &GalaxyTheme,
|
||||
) -> Box<dyn Element> {
|
||||
let (flag_name, background_color) = get_subshell_flag_info(&subshell_source, theme);
|
||||
let container = Container::new(
|
||||
Flex::row()
|
||||
.with_children([
|
||||
render_icon(font_size - 2., theme.foreground()),
|
||||
Text::new_inline(flag_name, font_family, font_size - 2.)
|
||||
.with_color(theme.foreground().into())
|
||||
.finish(),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.with_background(background_color)
|
||||
.with_padding_left(SUBSHELL_FLAG_HORIZONTAL_PADDING)
|
||||
.with_padding_right(SUBSHELL_FLAG_HORIZONTAL_PADDING)
|
||||
.with_padding_top(SUBSHELL_FLAG_VERTICAL_PADDING)
|
||||
.with_padding_bottom(SUBSHELL_FLAG_VERTICAL_PADDING)
|
||||
.finish();
|
||||
Stack::new().with_child(container).finish()
|
||||
}
|
||||
|
||||
fn render_icon(font_size: f32, fill: Fill) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::new(TERMINAL_ICON, fill).finish())
|
||||
.with_max_width(font_size)
|
||||
.with_max_height(font_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a separator above the first block of a subshell session. This is shown in compact mode
|
||||
/// instead of the subshell flag.
|
||||
pub fn render_subshell_separator(command: String, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Align::new(
|
||||
Flex::row()
|
||||
.with_children([
|
||||
render_icon(
|
||||
appearance.monospace_font_size() - 2.,
|
||||
appearance.theme().foreground(),
|
||||
),
|
||||
Text::new_inline(
|
||||
command,
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.finish(),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(SUBSHELL_FLAG_HORIZONTAL_PADDING)
|
||||
.with_padding_right(SUBSHELL_FLAG_HORIZONTAL_PADDING)
|
||||
.with_background(appearance.theme().subshell_background())
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
use anyhow::Result;
|
||||
use galaxy_util::path::ShellFamily;
|
||||
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use settings::macros::{maybe_define_setting, register_settings_events};
|
||||
use settings::{
|
||||
ChangeEventReason, RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
use strum_macros::EnumIter;
|
||||
|
||||
use crate::terminal::ssh::util::{parse_interactive_ssh_command, SshWormholeCommand};
|
||||
|
||||
// Cannot directly use Vec<Regex> here b/c Regex doesn't impl Eq, Serialize, and Deserialize.
|
||||
maybe_define_setting!(AddedSubshellCommands, group: WormholeSettings, {
|
||||
type: Vec<String>,
|
||||
default: Vec::new(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "wormhole.subshells.added_subshell_commands",
|
||||
description: "Additional regex patterns for commands that should be recognized as subshells.",
|
||||
});
|
||||
|
||||
maybe_define_setting!(SubshellCommandsDenylist, group: WormholeSettings, {
|
||||
type: Vec<String>,
|
||||
default: Vec::new(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "wormhole.subshells.subshell_commands_denylist",
|
||||
description: "Commands that should not trigger the subshell wormholing prompt.",
|
||||
});
|
||||
|
||||
maybe_define_setting!(SshHostsDenylist, group: WormholeSettings, {
|
||||
type: Vec<String>,
|
||||
default: Vec::new(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "wormhole.ssh.ssh_hosts_denylist",
|
||||
description: "SSH hosts that should not trigger the wormholing prompt.",
|
||||
});
|
||||
|
||||
maybe_define_setting!(EnableSshWormholing, group: WormholeSettings, {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "wormhole.ssh.enable_ssh_wormholing",
|
||||
description: "Whether to enable Galaxy features in SSH sessions.",
|
||||
});
|
||||
|
||||
// NOTE: This setting has been unified into `enable_ssh_wormholing` and is no
|
||||
// longer surfaced in the UI or used to gate any behavior. It is retained only
|
||||
// so the one-time migration (see `register`) can read a user's previous value
|
||||
// and forward it to `enable_ssh_wormholing`. It can be deleted in a future
|
||||
// release once the migration has shipped to all users.
|
||||
// The storage key and TOML path are intentionally kept identical to the old
|
||||
// `SshSettings::enable_ssh_wrapper` field for backward compatibility.
|
||||
maybe_define_setting!(EnableSshWrapper, group: WormholeSettings, {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "EnableSSHWrapper",
|
||||
toml_path: "wormhole.ssh.enable_legacy_ssh_wrapper",
|
||||
description: "Deprecated: unified into enable_ssh_wormholing. Retained only for one-time migration.",
|
||||
});
|
||||
|
||||
// NOTE: The tmux-based SSH wrapper is deprecated in favor of the remote-server SSH
|
||||
// extension. This setting is no longer surfaced in the UI or used to gate any behavior;
|
||||
// it is retained only so the one-time deprecation migration (see `register`) can read a
|
||||
// user's previous opt-in and reset it. It can be deleted in a future release once the
|
||||
// migration has shipped to all users.
|
||||
maybe_define_setting!(UseSshTmuxWrapper, group: WormholeSettings, {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "wormhole.ssh.use_ssh_tmux_wrapper",
|
||||
description: "Deprecated: whether to use a tmux-based wrapper for SSH wormholing.",
|
||||
});
|
||||
|
||||
// When set, the user previously opted into the now-deprecated tmux SSH wrapper and should
|
||||
// be shown a one-time inline banner pointing them to the remote-server SSH extension on
|
||||
// their next interactive SSH session. Set by the migration in `register`; cleared once the
|
||||
// banner has been shown.
|
||||
maybe_define_setting!(SshTmuxDeprecationNoticePending, group: WormholeSettings, {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "wormhole.ssh.ssh_tmux_deprecation_notice_pending",
|
||||
description: "Internal: whether to show the one-time tmux SSH deprecation notice.",
|
||||
});
|
||||
|
||||
/// Controls how Galaxy handles the SSH extension (remote server binary) when connecting
|
||||
/// to a remote host that does not already have it installed.
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Copy,
|
||||
Clone,
|
||||
EnumIter,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[schemars(
|
||||
description = "Controls Wormhole helper installation behavior.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum SshExtensionInstallMode {
|
||||
/// Always prompt the user before installing (default).
|
||||
#[default]
|
||||
AlwaysAsk,
|
||||
/// Automatically install and connect without prompting.
|
||||
AlwaysInstall,
|
||||
/// Never install; fall back to wrapper-only SSH wormholing.
|
||||
NeverInstall,
|
||||
}
|
||||
|
||||
maybe_define_setting!(SshExtensionInstallModeSetting, group: WormholeSettings, {
|
||||
type: SshExtensionInstallMode,
|
||||
default: SshExtensionInstallMode::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "wormhole.ssh.ssh_extension_install_mode",
|
||||
description: "Controls Wormhole helper installation behavior.",
|
||||
});
|
||||
|
||||
impl SshExtensionInstallMode {
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
SshExtensionInstallMode::AlwaysAsk => "Always ask",
|
||||
SshExtensionInstallMode::AlwaysInstall => "Always install",
|
||||
SshExtensionInstallMode::NeverInstall => "Never install",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normally we use the define_settings_group! macro for singleton models of settings like this.
|
||||
/// However, this model needs to do some extra processing on the added_subshell_commands and store
|
||||
/// an enriched representation in parsed_added_subshell_commands.
|
||||
pub struct WormholeSettings {
|
||||
/// A list of regexes that users can add to define new subshell-compatible commands. This
|
||||
/// represents the raw, serialized value. Therefore, it is Vec<String>.
|
||||
pub added_subshell_commands: AddedSubshellCommands,
|
||||
/// This is added_subshell_commands compiled to actual executable Regex. This is a Result as we
|
||||
/// cannot guarantee the values are valid regex. Even if we prevent them in the UI from entering
|
||||
/// invalid regex, it's possible that the serialized value in user-defaults is invalid. This
|
||||
/// needs to be kept up-to-date as added_subshell_commands changes. See the Self::register
|
||||
/// method for how this is done.
|
||||
pub parsed_added_subshell_commands: Vec<Result<Regex, regex::Error>>,
|
||||
/// A list of commands that we shouldn't attempt to wormhole. These can be added either b/c the
|
||||
/// "don't ask again" button was clicked in the trigger banner, or it was added explicitly on
|
||||
/// the Wormhole settings page. This represents the raw, serialized value.
|
||||
pub subshell_command_denylist: SubshellCommandsDenylist,
|
||||
/// This is subshell_command_denylist compiled to actual executable Regex. This is a Result as we
|
||||
/// cannot guarantee the values are valid regex. Even if we prevent them in the UI from entering
|
||||
/// invalid regex, it's possible that the serialized value in user-defaults is invalid. This
|
||||
/// needs to be kept up-to-date as subshell_command_denylist changes. See the Self::register
|
||||
/// method for how this is done.
|
||||
pub parsed_subshell_command_denylist: Vec<Result<Regex, regex::Error>>,
|
||||
|
||||
/// A list of hosts that we shouldn't attempt to wormhole. This supports regex.
|
||||
/// These can be added either b/c the "don't ask again" button was clicked in the trigger banner,
|
||||
/// or it was added explicitly on the Wormhole settings page.
|
||||
/// While this could live in the `SshSettings` group, the custom processing shared with the other
|
||||
/// subshell logic better justifies it living in the `WormholeSettings` group.
|
||||
pub ssh_hosts_denylist: SshHostsDenylist,
|
||||
/// This is ssh_hosts_denylist compiled to actual executable Regex. This is a Result as we
|
||||
/// cannot guarantee the values are valid regex. Even if we prevent them in the UI from entering
|
||||
/// invalid regex, it's possible that the serialized value in user-defaults is invalid. This
|
||||
/// needs to be kept up-to-date as ssh_hosts_denylist changes. See the Self::register
|
||||
/// method for how this is done.
|
||||
pub parsed_ssh_hosts_denylist: Vec<Result<Regex, regex::Error>>,
|
||||
|
||||
/// This setting controls whether we should ever wormhole ssh sessions.
|
||||
pub enable_ssh_wormholing: EnableSshWormholing,
|
||||
|
||||
/// Deprecated: unified into `enable_ssh_wormholing`. Retained only so the one-time
|
||||
/// migration in `register` can read and forward a user's previous opt-out. Not used to
|
||||
/// gate any behavior.
|
||||
pub enable_ssh_wrapper: EnableSshWrapper,
|
||||
|
||||
/// Deprecated opt-in for the tmux-based SSH wrapper. Retained only so the deprecation
|
||||
/// migration can read and reset a user's previous value; not used to gate any behavior.
|
||||
pub use_ssh_tmux_wrapper: UseSshTmuxWrapper,
|
||||
|
||||
/// When `true`, the user should be shown a one-time inline banner explaining that the
|
||||
/// tmux SSH wrapper is deprecated in favor of the remote-server SSH extension.
|
||||
pub ssh_tmux_deprecation_notice_pending: SshTmuxDeprecationNoticePending,
|
||||
|
||||
/// Controls the installation behavior for the SSH extension (remote server) when the binary
|
||||
/// is not installed on the remote host.
|
||||
pub ssh_extension_install_mode: SshExtensionInstallModeSetting,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
lazy_static! {
|
||||
/// Matches `wsl` commands which is for Windows Subsystem for Linux. Calling this can open
|
||||
/// interactive shells into Linux VMs.
|
||||
pub static ref WSL_SUBSHELL_REGEX: Regex = Regex::new(r"^wsl(\.exe)?($|\s)").expect("wsl regex must compile");
|
||||
/// We filter out `wsl` commands that are not for opening interactive shells.
|
||||
pub static ref WSL_IGNORE_REGEX: Regex = Regex::new(r" --(default-user|enable-wsl1|export|help|import|import-in-place|inbox|install|list|mount|no-distribution|no-launch|set-default|shutdown|status|terminate|uninstall|unmount|unregister|update|version|web-download)").expect("wsl ignore regex invalid");
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref POETRY_SUBSHELL_COMMAND_REGEX: Regex = Regex::new(r"^poetry\s+shell").expect("Poetry subshell regex invalid");
|
||||
pub static ref PIPENV_SUBSHELL_COMMAND_REGEX: Regex = Regex::new(r"^pipenv\s+shell").expect("pipenv subshell regex invalid");
|
||||
|
||||
/// These are known compatible subshell commands
|
||||
static ref SUBSHELL_COMMAND_REGEXES: Vec<Regex> = vec![
|
||||
// Matches "bash", "/bin/bash", any "./any/path/to/bash", plus the zsh/fish equivalents
|
||||
Regex::new(r"^/?([\w\.-]+/)*(bash|zsh|fish)$").expect("Direct shell regex invalid"),
|
||||
|
||||
// Matches "docker/podman run [whatever args] bash", plus zsh/fish equivalents.
|
||||
// Optionally allows single or double quotes around the shell name.
|
||||
Regex::new(r#"^(docker|podman)\s+run\s+.*?['"]?(bash|zsh|fish)['"]?$"#).expect("docker/podman run regex invalid"),
|
||||
|
||||
// Matches "docker/podman exec [whatever args] bash", plus zsh/fish equivalents.
|
||||
// Optionally allows single or double quotes around the shell name.
|
||||
Regex::new(r#"^(docker|podman)\s+exec\s+.*?['"]?(bash|zsh|fish)['"]?$"#).expect("docker/podman exec regex invalid"),
|
||||
|
||||
// Matches commands that spawn a poetry subshell.
|
||||
POETRY_SUBSHELL_COMMAND_REGEX.clone(),
|
||||
|
||||
// Matches commands that spawn a pipenv subshell.
|
||||
PIPENV_SUBSHELL_COMMAND_REGEX.clone(),
|
||||
|
||||
// Matches aws-vault's subshell-spawning exec command.
|
||||
Regex::new(r"^aws-vault\s+exec\b").expect("aws-vault regex invalid"),
|
||||
|
||||
// https://flox.dev/docs/reference/command-reference/flox-activate/
|
||||
// https://github.com/flox/flox/issues/2784
|
||||
Regex::new(r"^flox\s+(-\S+\s+)*activate\b").expect("flox activate regex invalid"),
|
||||
];
|
||||
}
|
||||
|
||||
/// There are two impl blocks for SubshellSettings. This block is an inlined version of the
|
||||
/// define_settings_group! macro, which is the basic template for user-defaults-backed settings.
|
||||
/// I have separated this stuff from the other impl block, which contains the subshell-specific
|
||||
/// logic, because this is basically boilerplate.
|
||||
impl WormholeSettings {
|
||||
fn new_from_storage(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let added_subshell_commands = AddedSubshellCommands::new_from_storage(ctx);
|
||||
let subshell_command_denylist = SubshellCommandsDenylist::new_from_storage(ctx);
|
||||
let ssh_hosts_denylist = SshHostsDenylist::new_from_storage(ctx);
|
||||
Self {
|
||||
parsed_added_subshell_commands: Self::parse_added_subshell_commands(
|
||||
&added_subshell_commands,
|
||||
),
|
||||
added_subshell_commands,
|
||||
parsed_subshell_command_denylist: Self::parse_subshell_command_denylist(
|
||||
&subshell_command_denylist,
|
||||
),
|
||||
subshell_command_denylist,
|
||||
parsed_ssh_hosts_denylist: Self::parse_ssh_hosts_denylist(&ssh_hosts_denylist),
|
||||
ssh_hosts_denylist,
|
||||
enable_ssh_wormholing: EnableSshWormholing::new_from_storage(ctx),
|
||||
enable_ssh_wrapper: EnableSshWrapper::new_from_storage(ctx),
|
||||
use_ssh_tmux_wrapper: UseSshTmuxWrapper::new_from_storage(ctx),
|
||||
ssh_tmux_deprecation_notice_pending: SshTmuxDeprecationNoticePending::new_from_storage(
|
||||
ctx,
|
||||
),
|
||||
ssh_extension_install_mode: SshExtensionInstallModeSetting::new_from_storage(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "integration_tests"))]
|
||||
#[allow(dead_code)]
|
||||
pub fn new_with_defaults(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
let added_subshell_commands = AddedSubshellCommands::new(None);
|
||||
let subshell_command_denylist = SubshellCommandsDenylist::new(None);
|
||||
let ssh_hosts_denylist = SshHostsDenylist::new(None);
|
||||
Self {
|
||||
parsed_added_subshell_commands: Self::parse_added_subshell_commands(
|
||||
&added_subshell_commands,
|
||||
),
|
||||
added_subshell_commands,
|
||||
parsed_subshell_command_denylist: Self::parse_subshell_command_denylist(
|
||||
&subshell_command_denylist,
|
||||
),
|
||||
subshell_command_denylist,
|
||||
parsed_ssh_hosts_denylist: Self::parse_ssh_hosts_denylist(&ssh_hosts_denylist),
|
||||
ssh_hosts_denylist,
|
||||
enable_ssh_wormholing: EnableSshWormholing::new(None),
|
||||
enable_ssh_wrapper: EnableSshWrapper::new(None),
|
||||
use_ssh_tmux_wrapper: UseSshTmuxWrapper::new(None),
|
||||
ssh_tmux_deprecation_notice_pending: SshTmuxDeprecationNoticePending::new(None),
|
||||
ssh_extension_install_mode: SshExtensionInstallModeSetting::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// This is different from the typical register method, as it also ensures
|
||||
/// that our parsed regexes stay in sync with the underlying data by
|
||||
/// subscribing to the model's change events at the app level.
|
||||
pub fn register(ctx: &mut AppContext) {
|
||||
let handle = ctx.add_singleton_model(Self::new_from_storage);
|
||||
ctx.subscribe_to_model(&handle, |settings, event, ctx| {
|
||||
settings.update(ctx, |me, _| match event {
|
||||
WormholeSettingsChangedEvent::AddedSubshellCommands { .. } => {
|
||||
me.parsed_added_subshell_commands =
|
||||
Self::parse_added_subshell_commands(&me.added_subshell_commands)
|
||||
}
|
||||
WormholeSettingsChangedEvent::SubshellCommandsDenylist { .. } => {
|
||||
me.parsed_subshell_command_denylist =
|
||||
Self::parse_subshell_command_denylist(&me.subshell_command_denylist)
|
||||
}
|
||||
WormholeSettingsChangedEvent::SshHostsDenylist { .. } => {
|
||||
me.parsed_ssh_hosts_denylist =
|
||||
Self::parse_ssh_hosts_denylist(&me.ssh_hosts_denylist)
|
||||
}
|
||||
WormholeSettingsChangedEvent::EnableSshWormholing { .. } => {}
|
||||
WormholeSettingsChangedEvent::EnableSshWrapper { .. } => {}
|
||||
WormholeSettingsChangedEvent::UseSshTmuxWrapper { .. } => {}
|
||||
WormholeSettingsChangedEvent::SshTmuxDeprecationNoticePending { .. } => {}
|
||||
WormholeSettingsChangedEvent::SshExtensionInstallModeSetting { .. } => {}
|
||||
});
|
||||
});
|
||||
|
||||
// One-time migration: if the user had explicitly set the legacy `enable_ssh_wrapper`
|
||||
// setting to `false` (via `wormhole.ssh.enable_legacy_ssh_wrapper = false` in their
|
||||
// TOML config or the old `EnableSSHWrapper` storage key), honour that intent by
|
||||
// disabling `enable_ssh_wormholing` — the canonical setting that now controls the
|
||||
// same behaviour. Resetting `enable_ssh_wrapper` back to its default (`true`) ensures
|
||||
// the migration does not run again on subsequent launches.
|
||||
handle.update(ctx, |me, ctx| {
|
||||
if me.enable_ssh_wrapper.is_value_explicitly_set() && !*me.enable_ssh_wrapper.value() {
|
||||
if let Err(e) = me.enable_ssh_wormholing.set_value(false, ctx) {
|
||||
log::error!(
|
||||
"Failed to migrate enable_ssh_wrapper → enable_ssh_wormholing: {e}"
|
||||
);
|
||||
}
|
||||
if let Err(e) = me.enable_ssh_wrapper.set_value(true, ctx) {
|
||||
log::error!("Failed to reset enable_ssh_wrapper after migration: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// One-time migration: the tmux-based SSH wrapper is deprecated in favor of the
|
||||
// remote-server SSH extension. If a user had explicitly opted into the tmux wrapper,
|
||||
// flag that we should show them a one-time deprecation notice on their next SSH, then
|
||||
// reset the opt-in. Because we only act when the value is still `true`, resetting it to
|
||||
// `false` ensures this migration does not run again.
|
||||
handle.update(ctx, |me, ctx| {
|
||||
if me.use_ssh_tmux_wrapper.is_value_explicitly_set() && *me.use_ssh_tmux_wrapper.value()
|
||||
{
|
||||
if let Err(e) = me.ssh_tmux_deprecation_notice_pending.set_value(true, ctx) {
|
||||
log::error!("Failed to set ssh_tmux_deprecation_notice_pending: {e}");
|
||||
}
|
||||
if let Err(e) = me.use_ssh_tmux_wrapper.set_value(false, ctx) {
|
||||
log::error!("Failed to reset use_ssh_tmux_wrapper: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
register_settings_events!(
|
||||
WormholeSettings,
|
||||
added_subshell_commands,
|
||||
AddedSubshellCommands,
|
||||
handle.clone(),
|
||||
ctx
|
||||
);
|
||||
|
||||
register_settings_events!(
|
||||
WormholeSettings,
|
||||
subshell_command_denylist,
|
||||
SubshellCommandsDenylist,
|
||||
handle.clone(),
|
||||
ctx
|
||||
);
|
||||
|
||||
register_settings_events!(
|
||||
WormholeSettings,
|
||||
enable_ssh_wormholing,
|
||||
EnableSshWormholing,
|
||||
handle.clone(),
|
||||
ctx
|
||||
);
|
||||
|
||||
register_settings_events!(
|
||||
WormholeSettings,
|
||||
enable_ssh_wrapper,
|
||||
EnableSshWrapper,
|
||||
handle.clone(),
|
||||
ctx
|
||||
);
|
||||
|
||||
register_settings_events!(
|
||||
WormholeSettings,
|
||||
use_ssh_tmux_wrapper,
|
||||
UseSshTmuxWrapper,
|
||||
handle.clone(),
|
||||
ctx
|
||||
);
|
||||
|
||||
register_settings_events!(
|
||||
WormholeSettings,
|
||||
ssh_tmux_deprecation_notice_pending,
|
||||
SshTmuxDeprecationNoticePending,
|
||||
handle.clone(),
|
||||
ctx
|
||||
);
|
||||
|
||||
register_settings_events!(
|
||||
WormholeSettings,
|
||||
ssh_extension_install_mode,
|
||||
SshExtensionInstallModeSetting,
|
||||
handle.clone(),
|
||||
ctx
|
||||
);
|
||||
|
||||
register_settings_events!(
|
||||
WormholeSettings,
|
||||
ssh_hosts_denylist,
|
||||
SshHostsDenylist,
|
||||
handle,
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This is also something that would normally be generated by
|
||||
/// define_settings_group!(WormholeSettings). Since we didn't use that macro we define it manually
|
||||
/// here. It's the event emitted by the setter methods when a setting value changes.
|
||||
pub enum WormholeSettingsChangedEvent {
|
||||
AddedSubshellCommands {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
SubshellCommandsDenylist {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
SshHostsDenylist {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
EnableSshWormholing {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
EnableSshWrapper {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
UseSshTmuxWrapper {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
SshTmuxDeprecationNoticePending {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
SshExtensionInstallModeSetting {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for WormholeSettings {
|
||||
type Event = WormholeSettingsChangedEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for WormholeSettings {}
|
||||
|
||||
/// This is the other impl block for this model. This one contains the actual subshell-specific
|
||||
/// logic.
|
||||
impl WormholeSettings {
|
||||
fn is_built_in_subshell_match(command: &str) -> bool {
|
||||
for command_regex in SUBSHELL_COMMAND_REGEXES.iter() {
|
||||
if command_regex.is_match(command) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if WSL_SUBSHELL_REGEX.is_match(command) && !WSL_IGNORE_REGEX.is_match(command) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// This function determines if we should ask the user whether they want to bootstrap a subshell.
|
||||
/// It determines this by matching their command against some hardcoded regexes and those added
|
||||
/// manually by the user.
|
||||
pub fn is_compatible_subshell_command(&self, command: &str, shell_family: ShellFamily) -> bool {
|
||||
let command = command.trim();
|
||||
if Self::is_built_in_subshell_match(command) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if SshWormholeCommand::matches(command).is_some_and(|command| command.is_ssh_like_command())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
for command_regex in self.parsed_added_subshell_commands.iter().flatten() {
|
||||
if command_regex.is_match(command) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// While in-band generators are our best option for wormholing ssh sessions from powershell, hard-code
|
||||
// the wormhole subshell banner to show up.
|
||||
if matches!(shell_family, ShellFamily::PowerShell)
|
||||
&& parse_interactive_ssh_command(command).is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// This function determines if we should ask the user whether they want to bootstrap an ssh session.
|
||||
/// It determines this by matching the host against a denylist of hosts, which can include regex.
|
||||
pub fn is_ssh_host_denylisted(&self, ssh_host: &str) -> bool {
|
||||
self.parsed_ssh_hosts_denylist
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|regex| regex.is_match(ssh_host.trim()))
|
||||
}
|
||||
|
||||
/// Returns whether the one-time tmux SSH deprecation notice should be shown to the user.
|
||||
pub fn should_show_tmux_deprecation_notice(&self) -> bool {
|
||||
*self.ssh_tmux_deprecation_notice_pending.value()
|
||||
}
|
||||
|
||||
/// Marks the one-time tmux SSH deprecation notice as shown so it is not shown again.
|
||||
pub fn mark_tmux_deprecation_notice_shown(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Err(e) = self
|
||||
.ssh_tmux_deprecation_notice_pending
|
||||
.set_value(false, ctx)
|
||||
{
|
||||
log::error!("Failed to clear ssh_tmux_deprecation_notice_pending: {e}");
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn parse_added_subshell_commands(
|
||||
added_subshell_commands: &AddedSubshellCommands,
|
||||
) -> Vec<Result<Regex, regex::Error>> {
|
||||
added_subshell_commands
|
||||
.iter()
|
||||
.map(|user_pattern| Regex::new(user_pattern))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_subshell_command_denylist(
|
||||
subshell_command_denylist: &SubshellCommandsDenylist,
|
||||
) -> Vec<Result<Regex, regex::Error>> {
|
||||
subshell_command_denylist
|
||||
.iter()
|
||||
.map(|user_pattern| Regex::new(user_pattern))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_ssh_hosts_denylist(
|
||||
ssh_hosts_denylist: &SshHostsDenylist,
|
||||
) -> Vec<Result<Regex, regex::Error>> {
|
||||
ssh_hosts_denylist
|
||||
.iter()
|
||||
.map(|user_pattern| Regex::new(user_pattern))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The user has indicated that they don't want to be asked to bootstrap a subshell for this
|
||||
/// command, so save it in user-defaults.
|
||||
pub fn denylist_subshell_command(
|
||||
&mut self,
|
||||
command_to_denylist: &str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut new_denylist = self.subshell_command_denylist.to_vec();
|
||||
new_denylist.push(command_to_denylist.trim().to_owned());
|
||||
self.subshell_command_denylist
|
||||
.set_value(new_denylist, ctx)
|
||||
.expect("subshell_command_denylist failed to serialize");
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// The user has indicated that they don't want to be asked to bootstrap an ssh session
|
||||
/// for this host, so save it in user-defaults.
|
||||
pub fn denylist_ssh_host(&mut self, host_to_denylist: &str, ctx: &mut ModelContext<Self>) {
|
||||
let mut new_denylist = self.ssh_hosts_denylist.to_vec();
|
||||
new_denylist.push(host_to_denylist.trim().to_owned());
|
||||
self.ssh_hosts_denylist
|
||||
.set_value(new_denylist, ctx)
|
||||
.expect("ssh_hosts_denylist failed to serialize");
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Add a new regex to the list of subshell-compatible commands.
|
||||
pub fn add_subshell_command(&mut self, command_to_add: &str, ctx: &mut ModelContext<Self>) {
|
||||
let mut new_added_commands_list = self.added_subshell_commands.to_vec();
|
||||
new_added_commands_list.push(command_to_add.trim().to_owned());
|
||||
|
||||
// The set_value method generated by the maybe_define_setting! macro will take
|
||||
// care of emitting the WormholeSettingsChangedEvent::AddedSubshellCommands event to keep
|
||||
// parsed_added_subshell_commands in sync.
|
||||
self.added_subshell_commands
|
||||
.set_value(new_added_commands_list, ctx)
|
||||
.expect("added_subshell_commands failed to serialize");
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Check if the user has asked us to remember a command and avoid asking to wormhole a subshell.
|
||||
pub fn is_denylisted_subshell_command(&self, command: &str) -> bool {
|
||||
let command = command.trim();
|
||||
self.parsed_subshell_command_denylist
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|command_regex| command_regex.is_match(command))
|
||||
}
|
||||
|
||||
pub fn remove_denylisted_subshell_command(
|
||||
&mut self,
|
||||
index: usize,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut new_denylist = self.subshell_command_denylist.to_vec();
|
||||
new_denylist.remove(index);
|
||||
self.subshell_command_denylist
|
||||
.set_value(new_denylist, ctx)
|
||||
.expect("subshell_command_denylist failed to serialize");
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn remove_added_subshell_command(&mut self, index: usize, ctx: &mut ModelContext<Self>) {
|
||||
let mut new_added_list = self.added_subshell_commands.to_vec();
|
||||
new_added_list.remove(index);
|
||||
self.added_subshell_commands
|
||||
.set_value(new_added_list, ctx)
|
||||
.expect("added_subshell_commands failed to serialize");
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn remove_denylisted_ssh_host(&mut self, index: usize, ctx: &mut ModelContext<Self>) {
|
||||
let mut new_denylist = self.ssh_hosts_denylist.to_vec();
|
||||
new_denylist.remove(index);
|
||||
self.ssh_hosts_denylist
|
||||
.set_value(new_denylist, ctx)
|
||||
.expect("ssh_hosts_denylist failed to serialize");
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "settings_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,172 @@
|
||||
use settings::Setting;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use super::WormholeSettings;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
#[test]
|
||||
fn test_parsed_subshell_commands_updated_via_self_subscription() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(WormholeSettings::as_ref(ctx)
|
||||
.parsed_added_subshell_commands
|
||||
.is_empty());
|
||||
});
|
||||
|
||||
WormholeSettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.added_subshell_commands
|
||||
.set_value(vec!["^my-custom-shell$".to_string()], ctx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// The parsed field must now contain the compiled regex.
|
||||
app.read(|ctx| {
|
||||
let parsed = &WormholeSettings::as_ref(ctx).parsed_added_subshell_commands;
|
||||
assert_eq!(
|
||||
parsed.len(),
|
||||
1,
|
||||
"self-subscription should have updated parsed field"
|
||||
);
|
||||
let regex = parsed[0].as_ref().expect("regex should compile");
|
||||
assert!(
|
||||
regex.is_match("my-custom-shell"),
|
||||
"compiled regex should match the command pattern"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Verify that a user who previously set `enable_legacy_ssh_wrapper = false`
|
||||
/// (old `SshSettings::enable_ssh_wrapper`) has that opt-out forwarded to
|
||||
/// `enable_ssh_wormholing` on first launch after the migration.
|
||||
#[test]
|
||||
fn test_enable_ssh_wrapper_false_migrates_to_enable_ssh_wormholing_false() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
// Simulate a user who had explicitly opted out of the legacy SSH wrapper.
|
||||
WormholeSettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.enable_ssh_wrapper
|
||||
.set_value(false, ctx)
|
||||
.expect("set enable_ssh_wrapper to false");
|
||||
});
|
||||
|
||||
// The migration in `register` already ran during `initialize_settings_for_tests`
|
||||
// (before we set the value above), so we trigger it manually by calling
|
||||
// `register` again on a fresh model to simulate a new launch with the value
|
||||
// pre-set in storage. We verify the outcome by checking state directly.
|
||||
//
|
||||
// Simpler approach: confirm the migration logic produces the right state
|
||||
// by applying it explicitly here.
|
||||
app.update(|ctx| {
|
||||
WormholeSettings::handle(ctx).update(ctx, |me, ctx| {
|
||||
if me.enable_ssh_wrapper.is_value_explicitly_set()
|
||||
&& !*me.enable_ssh_wrapper.value()
|
||||
{
|
||||
me.enable_ssh_wormholing
|
||||
.set_value(false, ctx)
|
||||
.expect("migration set enable_ssh_wormholing");
|
||||
me.enable_ssh_wrapper
|
||||
.set_value(true, ctx)
|
||||
.expect("migration reset enable_ssh_wrapper");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
let settings = WormholeSettings::as_ref(ctx);
|
||||
assert!(
|
||||
!*settings.enable_ssh_wormholing.value(),
|
||||
"enable_ssh_wormholing should be false after migration"
|
||||
);
|
||||
// The wrapper is reset to true so the migration condition
|
||||
// (`!*enable_ssh_wrapper.value()`) won't fire again on the next launch.
|
||||
assert!(
|
||||
*settings.enable_ssh_wrapper.value(),
|
||||
"enable_ssh_wrapper should be reset to true (default) after migration"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Verify that the default state (no legacy setting present) does not
|
||||
/// spuriously disable `enable_ssh_wormholing`.
|
||||
#[test]
|
||||
fn test_enable_ssh_wrapper_default_does_not_affect_enable_ssh_wormholing() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
app.read(|ctx| {
|
||||
let settings = WormholeSettings::as_ref(ctx);
|
||||
// Neither setting should be explicitly set — both default to true.
|
||||
assert!(
|
||||
!settings.enable_ssh_wrapper.is_value_explicitly_set(),
|
||||
"enable_ssh_wrapper should not be explicitly set in a fresh install"
|
||||
);
|
||||
assert!(
|
||||
*settings.enable_ssh_wormholing.value(),
|
||||
"enable_ssh_wormholing should remain true when no migration is needed"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_wsl_subshell_detection_success() {
|
||||
[
|
||||
"wsl",
|
||||
"wsl.exe",
|
||||
"wsl -d Ubuntu",
|
||||
"wsl --distribution Ubuntu",
|
||||
"wsl -u user",
|
||||
"wsl --cd /home/user",
|
||||
"wsl --system",
|
||||
"wsl --shell-type login",
|
||||
"wsl -d Ubuntu --cd /home/user -u username",
|
||||
"wsl.exe -d Ubuntu --cd /home/user -u username",
|
||||
]
|
||||
.iter()
|
||||
.for_each(|cmd| {
|
||||
assert!(
|
||||
WormholeSettings::is_built_in_subshell_match(cmd),
|
||||
"{} failed to match",
|
||||
*cmd
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_wsl_subshell_detection_fail() {
|
||||
[
|
||||
"wsl --install",
|
||||
"wsl --status",
|
||||
"wsl --list",
|
||||
"wsl --export Ubuntu file.tar",
|
||||
"wsl --uninstall",
|
||||
"wsl --shutdown",
|
||||
"wslfetch",
|
||||
"nowsl",
|
||||
"wsl --help",
|
||||
"wsl --version",
|
||||
"wsl --terminate Ubuntu",
|
||||
"wsl --unregister Ubuntu",
|
||||
"wsl --update",
|
||||
"wsl --import-in-place Ubuntu",
|
||||
"wsl --default-user root",
|
||||
"wsl --mount \\device",
|
||||
]
|
||||
.iter()
|
||||
.for_each(|cmd| {
|
||||
assert!(
|
||||
!WormholeSettings::is_built_in_subshell_match(cmd),
|
||||
"{} accidentally matched",
|
||||
*cmd
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use channel_versions::overrides::TargetOS;
|
||||
use galaxy_core::semantic_selection::SemanticSelection;
|
||||
use galaxy_core::ui::theme::GalaxyTheme;
|
||||
use parking_lot::RwLock;
|
||||
use warpui::elements::{
|
||||
Border, Container, CrossAxisAlignment, Flex, Icon, MainAxisAlignment, MainAxisSize,
|
||||
ParentElement, SelectableArea, SelectionHandle, Text,
|
||||
};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::render::HORIZONTAL_TEXT_MARGIN;
|
||||
use super::settings::WormholeSettings;
|
||||
use super::{render, subshell_bootstrap_success_block_bytes};
|
||||
use crate::ai::agent::ProgrammingLanguage;
|
||||
use crate::ai::blocklist::code_block::{render_runnable_code_snippet, CodeSnippetButtonHandles};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::model::terminal_model::SubshellInitializationInfo;
|
||||
use crate::terminal::shell::{Shell, ShellType};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
|
||||
const VERTICAL_TEXT_MARGIN: f32 = 16.;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WormholeSuccessBlockEvent {
|
||||
OpenWormholeSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum WormholeSuccessBlockAction {
|
||||
ClearAutoWormholeSnippet,
|
||||
OpenWormholeSettings,
|
||||
}
|
||||
|
||||
struct AutoWormholeSnippet {
|
||||
/// On subshell initialization, this will contain the output grid to display,
|
||||
/// containing info like how to auto-wormhole the subshell.
|
||||
output_grid: Cow<'static, str>,
|
||||
/// The output grid needs to be selectable to allow users to copy the command to their clipboard.
|
||||
selection_handle: SelectionHandle,
|
||||
selected_text: Arc<RwLock<Option<String>>>,
|
||||
|
||||
shell_type: ShellType,
|
||||
description: Cow<'static, str>,
|
||||
code_snippet_handles: CodeSnippetButtonHandles,
|
||||
can_write_to_rc: bool,
|
||||
}
|
||||
|
||||
pub struct WormholeSuccessBlock {
|
||||
spawning_command: String,
|
||||
auto_wormhole_snippet: Option<AutoWormholeSnippet>,
|
||||
}
|
||||
|
||||
impl WormholeSuccessBlock {
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new(
|
||||
spawning_command: String,
|
||||
subshell_info: Option<SubshellInitializationInfo>,
|
||||
shell: Shell,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&WormholeSettings::handle(ctx), move |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// Mac + Linux have the same behavior. We'd need to handle
|
||||
// getting the OS to write to the correct RC file.
|
||||
let remote_os = TargetOS::Linux;
|
||||
|
||||
let is_auto_wormhole_configured = subshell_info
|
||||
.as_ref()
|
||||
.map(|info| info.was_triggered_by_rc_file_snippet)
|
||||
.unwrap_or_default();
|
||||
|
||||
let auto_wormhole_snippet = if is_auto_wormhole_configured {
|
||||
None
|
||||
} else {
|
||||
subshell_info.and_then(|subshell_info| {
|
||||
// If wormholing wasn't triggered automatically, show a snippet about
|
||||
// how to automatically wormhole.
|
||||
(!subshell_info.was_triggered_by_rc_file_snippet).then(|| {
|
||||
let (command, is_executable) = subshell_bootstrap_success_block_bytes(
|
||||
&subshell_info,
|
||||
shell.shell_type(),
|
||||
remote_os,
|
||||
);
|
||||
if command.is_empty() {
|
||||
return ("".into(), false);
|
||||
}
|
||||
(
|
||||
String::from_utf8(command)
|
||||
.map(|content| {
|
||||
// Ensure a blank line between the output grid and the learn more link.
|
||||
content + "\n"
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
is_executable,
|
||||
)
|
||||
})
|
||||
})
|
||||
};
|
||||
let auto_wormhole_snippet = auto_wormhole_snippet.map(|(output_grid, can_write_to_rc)| {
|
||||
AutoWormholeSnippet {
|
||||
description: (if !output_grid.is_empty() {
|
||||
"Run the following to automatically Wormhole in the future:"
|
||||
} else {
|
||||
"In remote subshells, Galaxy runs commands in the background to power completions, syntax highlighting, and other features."
|
||||
}).into(),
|
||||
output_grid: output_grid.into(),
|
||||
selection_handle: Default::default(),
|
||||
selected_text: Default::default(),
|
||||
code_snippet_handles: Default::default(),
|
||||
shell_type: shell.shell_type(),
|
||||
can_write_to_rc,
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
spawning_command,
|
||||
auto_wormhole_snippet,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_text(&self) -> Option<String> {
|
||||
self.auto_wormhole_snippet
|
||||
.as_ref()
|
||||
.and_then(|snippet| snippet.selected_text.read().clone())
|
||||
}
|
||||
|
||||
pub fn render_spawning_command(
|
||||
&self,
|
||||
theme: &GalaxyTheme,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let spawning_command = self.spawning_command.clone();
|
||||
render::build_command_row(spawning_command, theme, appearance, true)
|
||||
.with_margin_bottom(VERTICAL_TEXT_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_title_ui(
|
||||
&self,
|
||||
theme: &GalaxyTheme,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let header_contents = render::build_header_row(
|
||||
"Session Wormholed",
|
||||
Icon::new(UiIcon::GalaxyLogo.into(), theme.active_ui_detail()),
|
||||
theme,
|
||||
appearance,
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::End)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(header_contents)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_margin(HORIZONTAL_TEXT_MARGIN)
|
||||
.with_margin_top(VERTICAL_TEXT_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Fired when a block ends and we are not in a Wormholed session.
|
||||
pub fn on_wormholed_session_complete(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.clear_auto_wormhole_snippet(ctx);
|
||||
}
|
||||
|
||||
pub fn clear_auto_wormhole_snippet(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.auto_wormhole_snippet = None;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// If there is an output grid to display, render it.
|
||||
pub fn render_output_grid(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let theme = appearance.theme();
|
||||
let auto_wormhole_snippet = self.auto_wormhole_snippet.as_ref()?;
|
||||
|
||||
if auto_wormhole_snippet.output_grid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let shell_language = ProgrammingLanguage::Shell(auto_wormhole_snippet.shell_type);
|
||||
let runnable_command = render_runnable_code_snippet(
|
||||
&auto_wormhole_snippet.output_grid,
|
||||
if auto_wormhole_snippet.can_write_to_rc {
|
||||
Some(&shell_language)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Some(Box::new({
|
||||
move |code_snippet, ctx| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::RunCommand(
|
||||
code_snippet.to_string(),
|
||||
));
|
||||
|
||||
ctx.dispatch_typed_action(WormholeSuccessBlockAction::ClearAutoWormholeSnippet);
|
||||
}
|
||||
})),
|
||||
Some(Box::new({
|
||||
move |code_snippet, ctx| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::CopyTextToClipboard(code_snippet));
|
||||
}
|
||||
})),
|
||||
Some(auto_wormhole_snippet.code_snippet_handles.clone()),
|
||||
app,
|
||||
);
|
||||
|
||||
let semantic_selection = SemanticSelection::as_ref(app);
|
||||
let selected_text = auto_wormhole_snippet.selected_text.clone();
|
||||
|
||||
// TODO(Simon): Implement full selection and copying functionality for the WormholeSuccessBlock.
|
||||
// Look to the `EnvVarCollectionBlock` for the existing implementation paradigm. We don't
|
||||
// yet have a robust way of ensuring that every aspect of text selection is implemented
|
||||
// properly, so be extra careful not to miss any details!
|
||||
let output_grid = SelectableArea::new(
|
||||
auto_wormhole_snippet.selection_handle.clone(),
|
||||
move |selection_args, _, _| {
|
||||
*selected_text.write() = selection_args.selection;
|
||||
},
|
||||
runnable_command,
|
||||
)
|
||||
.with_word_boundaries_policy(semantic_selection.word_boundary_policy())
|
||||
.with_smart_select_fn(semantic_selection.smart_select_fn())
|
||||
.finish();
|
||||
|
||||
let output_grid = Flex::column()
|
||||
.with_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
auto_wormhole_snippet.description.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(blended_colors::text_main(theme, theme.background()))
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_margin(HORIZONTAL_TEXT_MARGIN)
|
||||
.with_margin_bottom(VERTICAL_TEXT_MARGIN)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(output_grid)
|
||||
.with_horizontal_margin(HORIZONTAL_TEXT_MARGIN)
|
||||
.with_margin_bottom(VERTICAL_TEXT_MARGIN)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
Some(output_grid)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for WormholeSuccessBlock {
|
||||
type Event = WormholeSuccessBlockEvent;
|
||||
}
|
||||
|
||||
pub const WORMHOLE_SUCCESS_BLOCK_VISIBLE_KEY: &str = "WormholeSuccessBlockVisible";
|
||||
|
||||
impl View for WormholeSuccessBlock {
|
||||
fn ui_name() -> &'static str {
|
||||
"WormholeSuccessBlock"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut content = Flex::column();
|
||||
|
||||
content.add_children([
|
||||
self.render_title_ui(theme, appearance),
|
||||
self.render_spawning_command(theme, appearance),
|
||||
]);
|
||||
|
||||
if let Some(output_grid) = self.render_output_grid(app, appearance) {
|
||||
content.add_child(output_grid);
|
||||
}
|
||||
|
||||
Container::new(content.finish())
|
||||
.with_background(theme.foreground().with_opacity(10))
|
||||
.with_border(Border::top(1.).with_border_fill(theme.outline()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for WormholeSuccessBlock {
|
||||
type Action = WormholeSuccessBlockAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
WormholeSuccessBlockAction::OpenWormholeSettings => {
|
||||
ctx.emit(WormholeSuccessBlockEvent::OpenWormholeSettings);
|
||||
}
|
||||
WormholeSuccessBlockAction::ClearAutoWormholeSnippet => {
|
||||
self.clear_auto_wormhole_snippet(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::r#async::SpawnedFutureHandle;
|
||||
use galaxyui::{EntityId, SingletonEntity as _, ViewContext, ViewHandle};
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use super::success_block::WormholeSuccessBlock;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use crate::terminal::model::terminal_model::SubshellInitializationInfo;
|
||||
use crate::terminal::settings::TerminalSettings;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::{TerminalModel, TerminalView};
|
||||
|
||||
/// A unique identifier for a subshell separator.
|
||||
pub type SeparatorId = usize;
|
||||
|
||||
/// These are elements in the BlockList which are similar to inline banners but are smaller, and
|
||||
/// only meant to render in compact mode when their in-padding flag counterparts don't have enough
|
||||
/// space in the padding to render.
|
||||
#[derive(Default)]
|
||||
struct SubshellSeparatorState {
|
||||
/// The ID for the next separator to be created.
|
||||
next_separator_id: SeparatorId,
|
||||
|
||||
/// These are for rendering above the first block of a subshell session.
|
||||
separators: HashMap<SeparatorId, String>,
|
||||
}
|
||||
|
||||
impl SubshellSeparatorState {
|
||||
/// Returns the ID to assign to the next separator
|
||||
fn next_separator_id(&mut self) -> SeparatorId {
|
||||
let next_id = self.next_separator_id;
|
||||
self.next_separator_id += 1;
|
||||
next_id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SshBlockState {
|
||||
WormholeSuccess {
|
||||
handle: ViewHandle<WormholeSuccessBlock>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SshBlockState {
|
||||
pub fn should_prevent_input(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn get_block_view_id(&self) -> EntityId {
|
||||
match self {
|
||||
SshBlockState::WormholeSuccess { handle, .. } => handle.id(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_wormholed_session_complete(
|
||||
&self,
|
||||
ctx: &mut ViewContext<TerminalView>,
|
||||
) -> Option<EntityId> {
|
||||
match self {
|
||||
SshBlockState::WormholeSuccess { handle } => {
|
||||
handle.update(ctx, |block, ctx| {
|
||||
block.on_wormholed_session_complete(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Temporary state used to trigger Wormholing.
|
||||
#[derive(Default)]
|
||||
struct WormholeTriggerState {
|
||||
block_id: Option<BlockId>,
|
||||
|
||||
/// Lets us abort an attempt to auto wormhole if the subshell command
|
||||
/// hasn't completed.
|
||||
auto_wormhole_abort_handle: Option<SpawnedFutureHandle>,
|
||||
|
||||
/// The subshell banner waits 1s before showing. This is to see that the command stays running
|
||||
/// for a while without exiting. We store the abort handle here so that the
|
||||
/// TerminalEvent::BlockCompleted event can abort the banner.
|
||||
subshell_banner_abort_handle: Option<SpawnedFutureHandle>,
|
||||
|
||||
/// The command which may trigger ssh Wormholing
|
||||
pending_command: Option<String>,
|
||||
/// The Host which may trigger ssh Wormholing
|
||||
pending_wormhole_ssh_host: Option<String>,
|
||||
|
||||
/// Which, if any, SSH block is currently added to the blocklist.
|
||||
ssh_block_state: Option<SshBlockState>,
|
||||
|
||||
ssh_wormhole_timeout_handle: Option<SpawnedFutureHandle>,
|
||||
|
||||
shell_type: Option<ShellType>,
|
||||
|
||||
is_shell_detection_in_progress: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WormholeState {
|
||||
session_id: Option<SessionId>,
|
||||
|
||||
pending_state: Option<WormholeTriggerState>,
|
||||
/// Stores the metadata needed to render any separators above the first block of a subshell.
|
||||
subshell_separator_state: SubshellSeparatorState,
|
||||
/// A unique-enough ID that is used to validate that a timeout is still valid.
|
||||
timeout_id: u8,
|
||||
}
|
||||
|
||||
impl WormholeState {
|
||||
pub fn delete_state(&mut self) {
|
||||
self.pending_state.take();
|
||||
}
|
||||
|
||||
pub fn is_shell_detection_in_progress(&self) -> bool {
|
||||
self.pending_state
|
||||
.as_ref()
|
||||
.map(|state| state.is_shell_detection_in_progress)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_shell_detection_in_progress(&mut self) {
|
||||
if let Some(ref mut pending_state) = self.pending_state.as_mut() {
|
||||
pending_state.is_shell_detection_in_progress = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_shell_type(&mut self, shell_type: &ShellType) {
|
||||
let pending_state = self.pending_state.get_or_insert_with(Default::default);
|
||||
pending_state.shell_type = Some(shell_type.to_owned());
|
||||
pending_state.is_shell_detection_in_progress = false;
|
||||
}
|
||||
|
||||
pub fn get_shell_type(&self) -> Option<ShellType> {
|
||||
self.pending_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.shell_type)
|
||||
}
|
||||
|
||||
pub fn add_subshell_separator(
|
||||
&mut self,
|
||||
subshell_info: &SubshellInitializationInfo,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
ctx: &mut ViewContext<TerminalView>,
|
||||
) {
|
||||
let Some(command) = subshell_info.spawning_command.split_whitespace().next() else {
|
||||
return;
|
||||
};
|
||||
let separator_id = self.subshell_separator_state.next_separator_id();
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let terminal_spacing =
|
||||
TerminalSettings::as_ref(ctx).terminal_spacing(appearance.line_height_ratio(), ctx);
|
||||
let height = terminal_spacing.subshell_separator_height;
|
||||
self.subshell_separator_state
|
||||
.separators
|
||||
.insert(separator_id, command.to_owned());
|
||||
terminal_model
|
||||
.lock()
|
||||
.block_list_mut()
|
||||
.append_subshell_separator(separator_id, height);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn get_subshell_separators(&self) -> &HashMap<SeparatorId, String> {
|
||||
&self.subshell_separator_state.separators
|
||||
}
|
||||
|
||||
pub fn add_subshell_banner_abort_handle(&mut self, spawned_future_handle: SpawnedFutureHandle) {
|
||||
let pending_state = self.pending_state.get_or_insert_with(Default::default);
|
||||
pending_state.subshell_banner_abort_handle = Some(spawned_future_handle);
|
||||
}
|
||||
|
||||
pub fn take_subshell_banner_abort_handle(&mut self) -> Option<SpawnedFutureHandle> {
|
||||
self.pending_state
|
||||
.as_mut()
|
||||
.and_then(|state| state.subshell_banner_abort_handle.take())
|
||||
}
|
||||
|
||||
pub fn add_auto_wormhole_abort_handle(&mut self, spawned_future_handle: SpawnedFutureHandle) {
|
||||
let pending_state = self.pending_state.get_or_insert_with(Default::default);
|
||||
pending_state.auto_wormhole_abort_handle = Some(spawned_future_handle);
|
||||
}
|
||||
|
||||
pub fn abort_auto_wormhole(&mut self) {
|
||||
if let Some(abort_handle) = self
|
||||
.pending_state
|
||||
.as_mut()
|
||||
.and_then(|state| state.auto_wormhole_abort_handle.take())
|
||||
{
|
||||
abort_handle.abort();
|
||||
};
|
||||
}
|
||||
|
||||
pub fn add_ssh_wormhole_timeout_handle(&mut self, spawned_future_handle: SpawnedFutureHandle) {
|
||||
let pending_state = self.pending_state.get_or_insert_with(Default::default);
|
||||
pending_state.ssh_wormhole_timeout_handle = Some(spawned_future_handle);
|
||||
}
|
||||
|
||||
pub fn abort_ssh_wormhole_timeout(&mut self) {
|
||||
self.replace_timeout_id();
|
||||
if let Some(handle) = self
|
||||
.pending_state
|
||||
.as_mut()
|
||||
.and_then(|state| state.ssh_wormhole_timeout_handle.take())
|
||||
{
|
||||
handle.abort();
|
||||
};
|
||||
}
|
||||
|
||||
pub fn clear_ssh_block_state(&mut self) {
|
||||
if let Some(ref mut pending_state) = self.pending_state.as_mut() {
|
||||
pending_state.ssh_block_state = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ssh_block_state(&mut self, ssh_block_state: SshBlockState) {
|
||||
let pending_state = self.pending_state.get_or_insert_with(Default::default);
|
||||
pending_state.ssh_block_state = Some(ssh_block_state);
|
||||
}
|
||||
|
||||
pub fn ssh_block_state(&self) -> Option<&SshBlockState> {
|
||||
self.pending_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.ssh_block_state.as_ref())
|
||||
}
|
||||
|
||||
pub fn get_pending_ssh_host(&self) -> Option<String> {
|
||||
self.pending_state
|
||||
.as_ref()
|
||||
.and_then(|state: &WormholeTriggerState| state.pending_wormhole_ssh_host.clone())
|
||||
}
|
||||
|
||||
pub fn get_pending_ssh_command(&self) -> Option<String> {
|
||||
self.pending_state
|
||||
.as_ref()
|
||||
.and_then(|state: &WormholeTriggerState| state.pending_command.clone())
|
||||
}
|
||||
|
||||
pub fn take_pending_ssh_host(&mut self) -> Option<String> {
|
||||
self.pending_state
|
||||
.as_mut()
|
||||
.and_then(|state: &mut WormholeTriggerState| state.pending_wormhole_ssh_host.take())
|
||||
}
|
||||
|
||||
pub fn clear_pending_ssh_host(&mut self) {
|
||||
if let Some(ref mut pending_state) = self.pending_state.as_mut() {
|
||||
pending_state.pending_wormhole_ssh_host = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_pending_ssh_host(&mut self, command: String, ssh_host: Option<String>) {
|
||||
let pending_state = self.pending_state.get_or_insert_with(Default::default);
|
||||
pending_state.pending_command = Some(command);
|
||||
pending_state.pending_wormhole_ssh_host = ssh_host;
|
||||
}
|
||||
|
||||
pub fn set_block_id(&mut self, block_id: BlockId) {
|
||||
let pending_state = self.pending_state.get_or_insert_with(Default::default);
|
||||
pending_state.block_id = Some(block_id);
|
||||
}
|
||||
|
||||
pub fn block_id(&self) -> Option<BlockId> {
|
||||
self.pending_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.block_id.clone())
|
||||
}
|
||||
|
||||
pub fn timeout_id(&self) -> u8 {
|
||||
self.timeout_id
|
||||
}
|
||||
|
||||
/// Generates a new timeout ID. This is used to validate that a timeout is still valid.
|
||||
/// Call this to get a new timeout ID before starting a new timeout, or to invalidate
|
||||
/// an existing timeout.
|
||||
pub fn replace_timeout_id(&mut self) -> u8 {
|
||||
self.timeout_id = self.timeout_id.wrapping_add(1);
|
||||
self.timeout_id
|
||||
}
|
||||
|
||||
/// The terminal view should prevent typing
|
||||
pub fn should_prevent_input(&self) -> bool {
|
||||
let Some(state) = self.ssh_block_state() else {
|
||||
return false;
|
||||
};
|
||||
state.should_prevent_input()
|
||||
}
|
||||
|
||||
/// Called once whenever we get a local block completed, as opposed to a remote ssh block
|
||||
/// and we have a Wormhole Success block.
|
||||
fn on_wormholed_session_complete(
|
||||
&mut self,
|
||||
state: WormholeTriggerState,
|
||||
ctx: &mut ViewContext<TerminalView>,
|
||||
) -> Option<EntityId> {
|
||||
self.clear_ssh_block_state();
|
||||
ctx.notify();
|
||||
let Some(block) = &state.ssh_block_state else {
|
||||
return None;
|
||||
};
|
||||
block.on_wormholed_session_complete(ctx)
|
||||
}
|
||||
|
||||
pub fn on_wormhole_start(&mut self, active_session_id: Option<SessionId>) {
|
||||
self.session_id = active_session_id;
|
||||
}
|
||||
|
||||
/// Called whenever a block is completed, to determine whether a Wormholed session
|
||||
/// has been completed.
|
||||
pub fn get_completed_wormhole_session_id(
|
||||
&mut self,
|
||||
active_session_id: Option<SessionId>,
|
||||
ctx: &mut ViewContext<TerminalView>,
|
||||
) -> Option<EntityId> {
|
||||
if self.session_id.is_none() || active_session_id == self.session_id {
|
||||
return None;
|
||||
}
|
||||
if let Some(state) = self.pending_state.take() {
|
||||
return self.on_wormholed_session_complete(state, ctx);
|
||||
};
|
||||
None
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user