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:
2026-08-23 13:55:47 -05:00
parent f17642fc62
commit 7c106eecd5
147 changed files with 2208 additions and 1514 deletions
+106
View File
@@ -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
}
+309
View File
@@ -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()
}
+657
View File
@@ -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;
+172
View File
@@ -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
)
});
}
+311
View File
@@ -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);
}
}
}
}
+326
View File
@@ -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
}
}