Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::model::ansi::WarpificationUnavailableReason;
|
||||
use crate::terminal::warpify;
|
||||
use crate::terminal::warpify::render::apply_spacing_styles;
|
||||
use crate::terminal::warpify::render::build_description_row;
|
||||
use crate::terminal::warpify::settings::WarpifySettings;
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
use markdown_parser::FormattedText;
|
||||
use markdown_parser::FormattedTextFragment;
|
||||
use markdown_parser::FormattedTextLine;
|
||||
use warp_core::channel::ChannelState;
|
||||
use warp_core::ui::theme::WarpTheme;
|
||||
use warpui::elements::HighlightedHyperlink;
|
||||
use warpui::elements::Hoverable;
|
||||
use warpui::elements::Icon;
|
||||
use warpui::elements::MainAxisAlignment;
|
||||
use warpui::elements::MainAxisSize;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::ui_components::components::UiComponentStyles;
|
||||
use warpui::AppContext;
|
||||
use warpui::BlurContext;
|
||||
use warpui::FocusContext;
|
||||
use warpui::{
|
||||
elements::{Border, Container, CrossAxisAlignment, Flex, ParentElement},
|
||||
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
const TMUX_NOT_INSTALLED_ERROR: &str =
|
||||
"tmux is not installed on the remote machine. Please install tmux and try again.";
|
||||
const UNSUPPORTED_TMUX_VERSION_ERROR: &str =
|
||||
"The tmux version available on the remote machine is below 3.0. Please install tmux 3.0 or greater using a different method and try again.";
|
||||
const TMUX_FAILED_ERROR: &str =
|
||||
"tmux failed to execute on the remote machine. Please re-install tmux and try again.";
|
||||
const WARPIFY_TIMEOUT_ERROR: &str = "Warpifying the session hit a timeout.";
|
||||
const UNSUPPORTED_SHELL_ERROR: &str =
|
||||
"Unsupported shell. Please set bash, zsh, or fish as your default shell and try again.";
|
||||
const TMUX_INSTALL_FAILED_ERROR: &str =
|
||||
"The tmux install hit an unexpected error. Please install tmux manually and try again.";
|
||||
|
||||
const SSH_GITHUB_ISSUE_URL: &str = "https://github.com/warpdotdev/Warp/issues/new?assignees=&labels=Bugs,SSH-tmux&projects=&template=03_ssh_tmux.yml";
|
||||
|
||||
fn get_ssh_github_issue_url(title: &str) -> String {
|
||||
let url = if let Some(version) = ChannelState::app_version() {
|
||||
format!("{SSH_GITHUB_ISSUE_URL}&warp-version={version}")
|
||||
} else {
|
||||
SSH_GITHUB_ISSUE_URL.to_string()
|
||||
};
|
||||
// prepend the title with "SSH tmux bug report: " and uri encode it
|
||||
let title = format!("SSH tmux bug report: {title:?}");
|
||||
let title = urlencoding::encode(&title);
|
||||
format!("{url}&title={title}")
|
||||
}
|
||||
|
||||
impl WarpificationUnavailableReason {
|
||||
fn error_message(&self) -> &'static str {
|
||||
match self {
|
||||
WarpificationUnavailableReason::TmuxNotInstalled { .. } => TMUX_NOT_INSTALLED_ERROR,
|
||||
WarpificationUnavailableReason::UnsupportedTmuxVersion { .. } => {
|
||||
UNSUPPORTED_TMUX_VERSION_ERROR
|
||||
}
|
||||
WarpificationUnavailableReason::TmuxFailed => TMUX_FAILED_ERROR,
|
||||
WarpificationUnavailableReason::Timeout { .. } => WARPIFY_TIMEOUT_ERROR,
|
||||
WarpificationUnavailableReason::UnsupportedShell { .. } => UNSUPPORTED_SHELL_ERROR,
|
||||
WarpificationUnavailableReason::TmuxInstallFailed { .. } => TMUX_INSTALL_FAILED_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_title(&self) -> &'static str {
|
||||
match self {
|
||||
WarpificationUnavailableReason::TmuxNotInstalled { .. } => "tmux Not Installed",
|
||||
WarpificationUnavailableReason::UnsupportedTmuxVersion { .. } => {
|
||||
"Unsupported Tmux Version"
|
||||
}
|
||||
WarpificationUnavailableReason::TmuxFailed => "tmux Failed",
|
||||
WarpificationUnavailableReason::Timeout {
|
||||
is_tmux_install, ..
|
||||
} => {
|
||||
if *is_tmux_install {
|
||||
"tmux Install Timeout"
|
||||
} else {
|
||||
"SSH Warpify Timeout"
|
||||
}
|
||||
}
|
||||
WarpificationUnavailableReason::UnsupportedShell { .. } => "Unsupported Shell",
|
||||
WarpificationUnavailableReason::TmuxInstallFailed { .. } => "tmux Install Failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SshErrorBlockEvent {
|
||||
ContinueWithoutWarpification,
|
||||
WarpifyWithoutTmux,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SshErrorBlockAction {
|
||||
ContinueWithoutWarpification,
|
||||
WarpifyWithoutTmux,
|
||||
OpenUrl(String),
|
||||
AddSshHostToDenylist(String),
|
||||
Focus,
|
||||
}
|
||||
|
||||
pub struct SshErrorBlock {
|
||||
error_reason: WarpificationUnavailableReason,
|
||||
ssh_host: Option<String>,
|
||||
warpify_without_tmux_button_mouse_state: MouseStateHandle,
|
||||
continue_button_mouse_state: MouseStateHandle,
|
||||
report_link_highlight_index: HighlightedHyperlink,
|
||||
never_warpify_mouse_state_handle: MouseStateHandle,
|
||||
block_mouse_state: MouseStateHandle,
|
||||
is_focused: bool,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
SshErrorBlockAction::WarpifyWithoutTmux,
|
||||
id!(SshErrorBlock::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"escape",
|
||||
SshErrorBlockAction::ContinueWithoutWarpification,
|
||||
id!(SshErrorBlock::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"ctrl-c",
|
||||
SshErrorBlockAction::ContinueWithoutWarpification,
|
||||
id!(SshErrorBlock::ui_name()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
impl SshErrorBlock {
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new(error_reason: WarpificationUnavailableReason, ssh_host: Option<String>) -> Self {
|
||||
Self {
|
||||
error_reason,
|
||||
ssh_host,
|
||||
warpify_without_tmux_button_mouse_state: Default::default(),
|
||||
continue_button_mouse_state: Default::default(),
|
||||
report_link_highlight_index: Default::default(),
|
||||
never_warpify_mouse_state_handle: Default::default(),
|
||||
block_mouse_state: Default::default(),
|
||||
is_focused: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn should_show_report_to_warp_button(&self) -> bool {
|
||||
matches!(
|
||||
self.error_reason,
|
||||
WarpificationUnavailableReason::Timeout { .. }
|
||||
| WarpificationUnavailableReason::TmuxInstallFailed { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn render_title_ui(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
theme: &WarpTheme,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let header_contents = warpify::render::build_header_row(
|
||||
"Error Warpifying session",
|
||||
Icon::new(UiIcon::AlertTriangle.into(), theme.ui_error_color()),
|
||||
theme,
|
||||
appearance,
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish();
|
||||
|
||||
let right_hand_size = warpify::render::render_never_warpify_ssh_link(
|
||||
&self.ssh_host,
|
||||
app,
|
||||
appearance,
|
||||
self.never_warpify_mouse_state_handle.clone(),
|
||||
move |ctx, ssh_host| {
|
||||
ctx.dispatch_typed_action(SshErrorBlockAction::AddSshHostToDenylist(
|
||||
ssh_host.to_owned(),
|
||||
));
|
||||
},
|
||||
);
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::End)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(header_contents);
|
||||
|
||||
if let Some(right_hand_size) = right_hand_size {
|
||||
row.add_child(right_hand_size);
|
||||
}
|
||||
|
||||
warpify::render::apply_spacing_styles(Container::new(row.finish())).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SshErrorBlock {
|
||||
type Event = SshErrorBlockEvent;
|
||||
}
|
||||
|
||||
pub const SSH_ERROR_BLOCK_VISIBLE_KEY: &str = "SshErrorBlockVisible";
|
||||
|
||||
impl View for SshErrorBlock {
|
||||
fn ui_name() -> &'static str {
|
||||
"SshErrorBlock"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
content.add_child(self.render_title_ui(app, theme, appearance));
|
||||
|
||||
content.add_child(warpify::render::description_row(
|
||||
self.error_reason.error_message(),
|
||||
theme,
|
||||
appearance,
|
||||
));
|
||||
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
if self.should_show_report_to_warp_button() {
|
||||
let report_issue_text = build_description_row(FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("We are actively working on improving the stability of SSH in Warp. Please consider "),
|
||||
FormattedTextFragment::hyperlink("filing an issue", get_ssh_github_issue_url(self.error_reason.error_title())),
|
||||
FormattedTextFragment::plain_text(" on GitHub so we can better identify the problem."),
|
||||
])]),
|
||||
theme, appearance, self.report_link_highlight_index.clone())
|
||||
.with_hyperlink_font_color(theme.accent().into())
|
||||
.register_default_click_handlers(|link, ctx, _| {
|
||||
ctx.dispatch_typed_action(SshErrorBlockAction::OpenUrl(link.url));
|
||||
}).finish();
|
||||
content.add_child(apply_spacing_styles(Container::new(report_issue_text)).finish());
|
||||
}
|
||||
|
||||
let buttons = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.warpify_without_tmux_button_mouse_state.clone(),
|
||||
)
|
||||
.with_centered_text_label("Warpify without TMUX".into())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SshErrorBlockAction::WarpifyWithoutTmux)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
ui_builder
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.continue_button_mouse_state.clone(),
|
||||
)
|
||||
.with_centered_text_label("Continue without Warpification".into())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SshErrorBlockAction::ContinueWithoutWarpification)
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
|
||||
content.add_child(
|
||||
Container::new(buttons.finish())
|
||||
.with_uniform_margin(20.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Hoverable::new(self.block_mouse_state.clone(), |_| {
|
||||
Container::new(content.finish())
|
||||
.with_padding_top(10.)
|
||||
.with_background(theme.foreground().with_opacity(10))
|
||||
.with_border(Border::top(1.).with_border_fill(theme.outline()))
|
||||
.finish()
|
||||
})
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SshErrorBlockAction::Focus);
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
self.is_focused = true;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
if blur_ctx.is_self_blurred() {
|
||||
self.is_focused = false;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for SshErrorBlock {
|
||||
type Action = SshErrorBlockAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SshErrorBlockAction::WarpifyWithoutTmux => {
|
||||
ctx.emit(SshErrorBlockEvent::WarpifyWithoutTmux)
|
||||
}
|
||||
SshErrorBlockAction::ContinueWithoutWarpification => {
|
||||
ctx.emit(SshErrorBlockEvent::ContinueWithoutWarpification)
|
||||
}
|
||||
SshErrorBlockAction::OpenUrl(url) => {
|
||||
ctx.open_url(url);
|
||||
}
|
||||
SshErrorBlockAction::AddSshHostToDenylist(ssh_host) => {
|
||||
let settings = WarpifySettings::handle(ctx);
|
||||
settings.update(ctx, |warpify, ctx| {
|
||||
warpify.denylist_ssh_host(ssh_host, ctx);
|
||||
});
|
||||
ctx.emit(SshErrorBlockEvent::ContinueWithoutWarpification);
|
||||
ctx.notify()
|
||||
}
|
||||
SshErrorBlockAction::Focus => {
|
||||
self.focus(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::ai::blocklist::inline_action::requested_action::{ENTER_KEYSTROKE, ESCAPE_KEYSTROKE};
|
||||
use crate::ai::blocklist::inline_action::requested_script::{self, RequestedScriptMouseStates};
|
||||
use crate::ai::blocklist::inline_action::requested_script::{RequestedScriptStatus, TitledScript};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::model::ansi::SystemDetails;
|
||||
use crate::terminal::model::escape_sequences;
|
||||
use crate::terminal::warpify::render;
|
||||
use crate::terminal::warpify::settings::WarpifySettings;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use warp_core::ui::theme::WarpTheme;
|
||||
use warpui::elements::{
|
||||
FormattedTextElement, HighlightedHyperlink, Hoverable, Icon, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle,
|
||||
};
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::ui_components::toggle_menu::ToggleMenuStateHandle;
|
||||
use warpui::{
|
||||
elements::{Border, Container, CrossAxisAlignment, Flex, ParentElement},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use warpui::{BlurContext, FocusContext};
|
||||
|
||||
pub const WHY_INSTALL_TMUX_URL: &str =
|
||||
"https://docs.warp.dev/terminal/warpify/ssh#why-do-i-need-tmux-on-the-remote-machine";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TmuxInstallMethod {
|
||||
pub script: String,
|
||||
pub should_use_package_manager: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SshInstallTmuxBlockEvent {
|
||||
InstallTmuxAndWarpify(TmuxInstallMethod),
|
||||
ToggleScriptVisibility,
|
||||
Cancel,
|
||||
Interrupt,
|
||||
ToggleTmuxInstallVisibility,
|
||||
UnhideTmuxInstall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ScriptTarget {
|
||||
First,
|
||||
Second,
|
||||
Toggle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SshInstallTmuxBlockAction {
|
||||
SetInstallScriptChoice(ScriptTarget),
|
||||
OnToggleInstallScriptChoice,
|
||||
InstallTmux,
|
||||
/// If the script is pending, this means show or hide the full script.
|
||||
/// If the script is running, this means show or hide the detail (ie, the long-running block).
|
||||
ToggleVisibility,
|
||||
AddSshHostToDenylist(String),
|
||||
Cancel,
|
||||
Interrupt,
|
||||
Focus,
|
||||
}
|
||||
|
||||
pub struct SshKeyEvent {
|
||||
is_ctrl_c: bool,
|
||||
}
|
||||
|
||||
impl SshKeyEvent {
|
||||
pub fn from_chars(chars: &str) -> Self {
|
||||
Self {
|
||||
is_ctrl_c: chars == "\x03",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_bytes(chars: &[u8]) -> Self {
|
||||
Self {
|
||||
is_ctrl_c: chars == [escape_sequences::C0::ETX],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_ctrl_c(&self) -> bool {
|
||||
self.is_ctrl_c
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SshInstallTmuxBlock {
|
||||
requested_script_mouse_states: RequestedScriptMouseStates,
|
||||
why_install_tmux_highlight_index: HighlightedHyperlink,
|
||||
never_warpify_mouse_state_handle: MouseStateHandle,
|
||||
block_mouse_state: MouseStateHandle,
|
||||
is_focused: bool,
|
||||
is_collapsed: bool,
|
||||
show_tmux_install_block: bool,
|
||||
script_status: RequestedScriptStatus,
|
||||
system_details: SystemDetails,
|
||||
/// The script to install tmux locally, in a ~/.warp directory
|
||||
tmux_local_install_script: String,
|
||||
ssh_host: Option<String>,
|
||||
ssh_command: String,
|
||||
system_install_state: Option<SystemInstallState>,
|
||||
outdated_version: bool,
|
||||
}
|
||||
|
||||
pub struct SystemInstallState {
|
||||
/// The script to install tmux via a package manager, which requires root access
|
||||
tmux_system_install_script: String,
|
||||
toggle_menu_mouse_states: Vec<MouseStateHandle>,
|
||||
toggle_menu_state_handle: ToggleMenuStateHandle,
|
||||
is_first_script_active: bool,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
SshInstallTmuxBlockAction::InstallTmux,
|
||||
id!(SshInstallTmuxBlock::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"escape",
|
||||
SshInstallTmuxBlockAction::Cancel,
|
||||
id!(SshInstallTmuxBlock::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"ctrl-c",
|
||||
SshInstallTmuxBlockAction::Interrupt,
|
||||
id!(SshInstallTmuxBlock::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"down",
|
||||
SshInstallTmuxBlockAction::ToggleVisibility,
|
||||
id!(SshInstallTmuxBlock::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"tab",
|
||||
SshInstallTmuxBlockAction::SetInstallScriptChoice(ScriptTarget::Toggle),
|
||||
id!(SshInstallTmuxBlock::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"left",
|
||||
SshInstallTmuxBlockAction::SetInstallScriptChoice(ScriptTarget::First),
|
||||
id!(SshInstallTmuxBlock::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"right",
|
||||
SshInstallTmuxBlockAction::SetInstallScriptChoice(ScriptTarget::Second),
|
||||
id!(SshInstallTmuxBlock::ui_name()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
impl SshInstallTmuxBlock {
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new(
|
||||
system_details: SystemDetails,
|
||||
tmux_local_install_script: String,
|
||||
tmux_system_install_script: Option<String>,
|
||||
ssh_command: String,
|
||||
ssh_host: Option<String>,
|
||||
outdated_version: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
requested_script_mouse_states: Default::default(),
|
||||
why_install_tmux_highlight_index: Default::default(),
|
||||
never_warpify_mouse_state_handle: Default::default(),
|
||||
block_mouse_state: Default::default(),
|
||||
is_focused: false,
|
||||
is_collapsed: true,
|
||||
show_tmux_install_block: false,
|
||||
script_status: RequestedScriptStatus::WaitingForUser,
|
||||
system_details,
|
||||
tmux_local_install_script,
|
||||
ssh_host,
|
||||
ssh_command,
|
||||
outdated_version,
|
||||
system_install_state: tmux_system_install_script.map(|tmux_root_install_script| {
|
||||
SystemInstallState {
|
||||
tmux_system_install_script: tmux_root_install_script,
|
||||
toggle_menu_mouse_states: vec![Default::default(), Default::default()],
|
||||
toggle_menu_state_handle: Default::default(),
|
||||
is_first_script_active: true,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_install_method(&self) -> TmuxInstallMethod {
|
||||
if let Some(ref system_install_state) = self.system_install_state {
|
||||
// The user has selected the first script, which is the system install
|
||||
if system_install_state.is_first_script_active {
|
||||
return TmuxInstallMethod {
|
||||
script: system_install_state.tmux_system_install_script.clone(),
|
||||
should_use_package_manager: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
TmuxInstallMethod {
|
||||
script: self.tmux_local_install_script.clone(),
|
||||
should_use_package_manager: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn system_details(&self) -> SystemDetails {
|
||||
self.system_details.clone()
|
||||
}
|
||||
|
||||
pub fn emit_install_tmux(
|
||||
&mut self,
|
||||
install_method: TmuxInstallMethod,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.script_status = RequestedScriptStatus::Running;
|
||||
ctx.emit(SshInstallTmuxBlockEvent::InstallTmuxAndWarpify(
|
||||
install_method,
|
||||
));
|
||||
ctx.notify()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SshInstallTmuxBlock {
|
||||
type Event = SshInstallTmuxBlockEvent;
|
||||
}
|
||||
|
||||
impl SshInstallTmuxBlock {
|
||||
/// Returns `true` if the script was previously visible and is now collapsed.
|
||||
pub fn collapse_script(&mut self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
let was_expanded = !self.is_collapsed;
|
||||
if was_expanded {
|
||||
self.is_collapsed = true;
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn render_system_install_ui(
|
||||
&self,
|
||||
SystemInstallState {
|
||||
is_first_script_active,
|
||||
tmux_system_install_script,
|
||||
toggle_menu_mouse_states,
|
||||
toggle_menu_state_handle,
|
||||
..
|
||||
}: &SystemInstallState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let package_manager = &self.system_details.package_manager;
|
||||
Container::new(requested_script::render_requested_scripts(
|
||||
TitledScript {
|
||||
title: format!("Install with {package_manager}"),
|
||||
content: tmux_system_install_script.to_string(),
|
||||
},
|
||||
TitledScript {
|
||||
title: "Install to ~/.warp".to_string(),
|
||||
content: self.tmux_local_install_script.clone(),
|
||||
},
|
||||
*is_first_script_active,
|
||||
self.script_status.clone(),
|
||||
self.is_collapsed,
|
||||
self.show_tmux_install_block,
|
||||
move |ctx, _, _| ctx.dispatch_typed_action(SshInstallTmuxBlockAction::ToggleVisibility),
|
||||
|ctx| ctx.dispatch_typed_action(SshInstallTmuxBlockAction::InstallTmux),
|
||||
|ctx| ctx.dispatch_typed_action(SshInstallTmuxBlockAction::Cancel),
|
||||
&ENTER_KEYSTROKE,
|
||||
&ESCAPE_KEYSTROKE,
|
||||
&self.requested_script_mouse_states,
|
||||
toggle_menu_mouse_states.clone(),
|
||||
toggle_menu_state_handle.clone(),
|
||||
Rc::new(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SshInstallTmuxBlockAction::OnToggleInstallScriptChoice)
|
||||
}),
|
||||
self.is_focused,
|
||||
380.,
|
||||
app,
|
||||
))
|
||||
.with_margin_top(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_local_install_ui(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let header = if self.is_focused {
|
||||
"Run this script to install tmux?"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Container::new(requested_script::render_requested_script(
|
||||
header,
|
||||
&self.tmux_local_install_script,
|
||||
self.script_status.clone(),
|
||||
self.is_collapsed,
|
||||
self.show_tmux_install_block,
|
||||
move |ctx, _, _| ctx.dispatch_typed_action(SshInstallTmuxBlockAction::ToggleVisibility),
|
||||
|ctx| ctx.dispatch_typed_action(SshInstallTmuxBlockAction::InstallTmux),
|
||||
|ctx| ctx.dispatch_typed_action(SshInstallTmuxBlockAction::Cancel),
|
||||
&ENTER_KEYSTROKE,
|
||||
&ESCAPE_KEYSTROKE,
|
||||
&self.requested_script_mouse_states,
|
||||
self.is_focused,
|
||||
app,
|
||||
))
|
||||
.with_margin_top(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_title_ui(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
theme: &WarpTheme,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let header_contents = render::build_header_row(
|
||||
"Install tmux?",
|
||||
Icon::new(UiIcon::Warp.into(), theme.active_ui_detail()),
|
||||
theme,
|
||||
appearance,
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish();
|
||||
|
||||
let is_awaiting_action = self.script_status == RequestedScriptStatus::WaitingForUser;
|
||||
|
||||
let right_hand_size = is_awaiting_action
|
||||
.then(|| {
|
||||
render::render_never_warpify_ssh_link(
|
||||
&self.ssh_host,
|
||||
app,
|
||||
appearance,
|
||||
self.never_warpify_mouse_state_handle.clone(),
|
||||
move |ctx, ssh_host| {
|
||||
ctx.dispatch_typed_action(SshInstallTmuxBlockAction::AddSshHostToDenylist(
|
||||
ssh_host.to_owned(),
|
||||
));
|
||||
},
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::End)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(header_contents);
|
||||
|
||||
if let Some(right_hand_size) = right_hand_size {
|
||||
row.add_child(right_hand_size);
|
||||
}
|
||||
|
||||
render::apply_spacing_styles(Container::new(row.finish())).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl View for SshInstallTmuxBlock {
|
||||
fn ui_name() -> &'static str {
|
||||
"SshInstallTmuxBlock"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
content.add_child(self.render_title_ui(app, theme, appearance));
|
||||
|
||||
content.add_child(
|
||||
render::build_command_row(self.ssh_command.clone(), theme, appearance, false).finish(),
|
||||
);
|
||||
|
||||
let explanation = if self.outdated_version {
|
||||
"In order to Warpify your SSH session, a more recent version of tmux (>=3.0) must be installed. "
|
||||
} else {
|
||||
"In order to Warpify your SSH session, tmux must be installed. "
|
||||
};
|
||||
|
||||
let warpify_description = vec![
|
||||
FormattedTextFragment::plain_text(explanation),
|
||||
FormattedTextFragment::hyperlink("Why do I need tmux?", WHY_INSTALL_TMUX_URL),
|
||||
];
|
||||
|
||||
let text_color =
|
||||
blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1());
|
||||
|
||||
let warpify_description = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(warpify_description)]),
|
||||
appearance.monospace_font_size(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_family(),
|
||||
text_color,
|
||||
self.why_install_tmux_highlight_index.clone(),
|
||||
)
|
||||
.with_hyperlink_font_color(appearance.theme().accent().into_solid())
|
||||
.register_default_click_handlers(|url, _, ctx| {
|
||||
ctx.open_url(&url.url);
|
||||
})
|
||||
.finish();
|
||||
|
||||
content
|
||||
.add_child(render::apply_spacing_styles(Container::new(warpify_description)).finish());
|
||||
|
||||
if let Some(root_install_state) = &self.system_install_state {
|
||||
content.add_child(self.render_system_install_ui(root_install_state, app));
|
||||
} else {
|
||||
content.add_child(self.render_local_install_ui(app));
|
||||
}
|
||||
|
||||
Hoverable::new(self.block_mouse_state.clone(), |_| {
|
||||
Container::new(content.finish())
|
||||
.with_padding_top(10.)
|
||||
.with_background(theme.foreground().with_opacity(10))
|
||||
.with_border(Border::top(1.).with_border_fill(theme.outline()))
|
||||
.finish()
|
||||
})
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SshInstallTmuxBlockAction::Focus);
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
self.is_focused = true;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
if blur_ctx.is_self_blurred() {
|
||||
self.is_focused = false;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for SshInstallTmuxBlock {
|
||||
type Action = SshInstallTmuxBlockAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
let is_pending = self.script_status == RequestedScriptStatus::WaitingForUser;
|
||||
match (action, is_pending) {
|
||||
(SshInstallTmuxBlockAction::Cancel, true) => ctx.emit(SshInstallTmuxBlockEvent::Cancel),
|
||||
(SshInstallTmuxBlockAction::OnToggleInstallScriptChoice, true) => {
|
||||
if let Some(ref mut root_install_state) = self.system_install_state {
|
||||
root_install_state.is_first_script_active =
|
||||
!root_install_state.is_first_script_active;
|
||||
}
|
||||
}
|
||||
(SshInstallTmuxBlockAction::SetInstallScriptChoice(target), true) => {
|
||||
if let Some(ref mut root_install_state) = self.system_install_state {
|
||||
let new_index = match target {
|
||||
ScriptTarget::First => 0,
|
||||
ScriptTarget::Second => 1,
|
||||
ScriptTarget::Toggle => root_install_state.is_first_script_active as usize,
|
||||
};
|
||||
root_install_state
|
||||
.toggle_menu_state_handle
|
||||
.set_selected_idx(new_index);
|
||||
root_install_state.is_first_script_active = new_index == 0;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
(SshInstallTmuxBlockAction::ToggleVisibility, true) => {
|
||||
self.is_collapsed = !self.is_collapsed;
|
||||
ctx.focus_self();
|
||||
ctx.emit(SshInstallTmuxBlockEvent::ToggleScriptVisibility);
|
||||
ctx.notify();
|
||||
}
|
||||
(SshInstallTmuxBlockAction::ToggleVisibility, false) => {
|
||||
self.show_tmux_install_block = !self.show_tmux_install_block;
|
||||
ctx.emit(SshInstallTmuxBlockEvent::ToggleTmuxInstallVisibility);
|
||||
ctx.notify();
|
||||
}
|
||||
(SshInstallTmuxBlockAction::InstallTmux, true) => {
|
||||
let selected_root_access_option = self.get_install_method();
|
||||
self.is_collapsed = true;
|
||||
self.show_tmux_install_block = true;
|
||||
ctx.emit(SshInstallTmuxBlockEvent::UnhideTmuxInstall);
|
||||
self.emit_install_tmux(selected_root_access_option, ctx);
|
||||
}
|
||||
(SshInstallTmuxBlockAction::Interrupt, _) => {
|
||||
ctx.emit(SshInstallTmuxBlockEvent::Interrupt);
|
||||
}
|
||||
(SshInstallTmuxBlockAction::AddSshHostToDenylist(ssh_host), true) => {
|
||||
let settings = WarpifySettings::handle(ctx);
|
||||
settings.update(ctx, |warpify, ctx| {
|
||||
warpify.denylist_ssh_host(ssh_host, ctx);
|
||||
});
|
||||
ctx.emit(SshInstallTmuxBlockEvent::Cancel);
|
||||
ctx.notify();
|
||||
}
|
||||
(SshInstallTmuxBlockAction::Focus, _) => {
|
||||
self.focus(ctx);
|
||||
}
|
||||
(_, false) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If we have an "install tmux" script bundled into the app that matches the system details, then returns
|
||||
/// the script as a string. Otherwise, returns None.
|
||||
#[cfg(not(test))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn install_tmux_script(system: &SystemDetails, app: &AppContext) -> Option<String> {
|
||||
use asset_macro::bundled_asset;
|
||||
use warpui::assets::asset_cache::{AssetCache, AssetState};
|
||||
|
||||
let asset_source = match (
|
||||
system.operating_system.as_str(),
|
||||
system.package_manager.as_str(),
|
||||
system.shell.as_str(),
|
||||
) {
|
||||
("Linux", _, "bash" | "zsh") => {
|
||||
bundled_asset!("ssh/bash_zsh/install_tmux_and_warpify_linux.sh")
|
||||
}
|
||||
("Linux", _, "fish") => {
|
||||
bundled_asset!("ssh/fish/install_tmux_and_warpify_linux.sh")
|
||||
}
|
||||
("Darwin", "homebrew", "bash" | "zsh") => {
|
||||
bundled_asset!("ssh/bash_zsh/install_tmux_and_warpify_brew.sh")
|
||||
}
|
||||
("Darwin", "homebrew", "fish") => {
|
||||
bundled_asset!("ssh/fish/install_tmux_and_warpify_brew.sh")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
match AssetCache::as_ref(app).load_asset::<String>(asset_source) {
|
||||
AssetState::Loaded { data } => Some(data.to_string()),
|
||||
_ => panic!("install tmux script should be available as a string"),
|
||||
}
|
||||
}
|
||||
|
||||
/// If we have an "install tmux via root" script bundled into the app that matches the system details, then returns
|
||||
/// the script as a string. Otherwise, returns None.
|
||||
#[cfg(not(test))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn install_root_tmux_script(
|
||||
system: &SystemDetails,
|
||||
app: &AppContext,
|
||||
can_run_sudo: bool,
|
||||
) -> Option<String> {
|
||||
use asset_macro::bundled_asset;
|
||||
use warpui::assets::asset_cache::{AssetCache, AssetState};
|
||||
|
||||
let asset_source = match (
|
||||
system.operating_system.as_str(),
|
||||
system.package_manager.as_str(),
|
||||
system.shell.as_str(),
|
||||
) {
|
||||
("Linux", "apt", "bash" | "zsh") => {
|
||||
bundled_asset!("ssh/bash_zsh/root/install_tmux_and_warpify_apt.sh")
|
||||
}
|
||||
("Linux", "dnf", "bash" | "zsh") => {
|
||||
bundled_asset!("ssh/bash_zsh/root/install_tmux_and_warpify_dnf.sh")
|
||||
}
|
||||
("Linux", "pacman", "bash" | "zsh") => {
|
||||
bundled_asset!("ssh/bash_zsh/root/install_tmux_and_warpify_pacman.sh")
|
||||
}
|
||||
("Linux", "yum", "bash" | "zsh") => {
|
||||
bundled_asset!("ssh/bash_zsh/root/install_tmux_and_warpify_yum.sh")
|
||||
}
|
||||
("Linux", "zypper", "bash" | "zsh") => {
|
||||
bundled_asset!("ssh/bash_zsh/root/install_tmux_and_warpify_zypper.sh")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let asset_source = match AssetCache::as_ref(app).load_asset::<String>(asset_source) {
|
||||
AssetState::Loaded { data } => data.to_string(),
|
||||
_ => panic!("install tmux script should be available as a string"),
|
||||
};
|
||||
if !can_run_sudo {
|
||||
return Some(asset_source.replace("sudo ", ""));
|
||||
}
|
||||
Some(asset_source)
|
||||
}
|
||||
|
||||
/// This method has a separate test-only implementation so we don't try to access a bundled
|
||||
/// asset when executing a unit test
|
||||
#[cfg(test)]
|
||||
#[allow(unused_variables)]
|
||||
pub fn install_tmux_script(system: &SystemDetails, app: &AppContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// This method has a separate test-only implementation so we don't try to access a bundled
|
||||
/// asset when executing a unit test
|
||||
#[cfg(test)]
|
||||
#[allow(unused_variables)]
|
||||
pub fn install_root_tmux_script(
|
||||
system: &SystemDetails,
|
||||
app: &AppContext,
|
||||
can_run_sudo: bool,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod error;
|
||||
pub mod install_tmux;
|
||||
pub mod root_access;
|
||||
pub mod ssh_detection;
|
||||
pub mod util;
|
||||
pub mod warpify;
|
||||
|
||||
pub const SSH_WARPIFY_TIMEOUT_DURATION: Duration = Duration::from_secs(8);
|
||||
@@ -0,0 +1,25 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged, rename_all = "snake_case")]
|
||||
pub enum RootAccess {
|
||||
IsRoot,
|
||||
CanRunSudo,
|
||||
#[default]
|
||||
NoRootAccess,
|
||||
}
|
||||
|
||||
impl FromStr for RootAccess {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
match input {
|
||||
"is_root" => Ok(RootAccess::IsRoot),
|
||||
"can_run_sudo" => Ok(RootAccess::CanRunSudo),
|
||||
"no_root_access" => Ok(RootAccess::NoRootAccess),
|
||||
_ => Err(anyhow::anyhow!("Invalid RootAccess")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_core::{features::FeatureFlag, settings::Setting};
|
||||
use warp_util::path::ShellFamily;
|
||||
|
||||
use crate::terminal::warpify::settings::WarpifySettings;
|
||||
|
||||
/// The different possible outcomes of detecting an interactive SSH session.
|
||||
/// Also the payload for the [`crate::server::telemetry::TelemetryEvent::SshInteractiveSessionDetected`] event.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum SshInteractiveSessionDetected {
|
||||
#[serde(rename = "feature_disabled")]
|
||||
FeatureDisabled,
|
||||
#[serde(rename = "host_denylisted")]
|
||||
HostDenylisted,
|
||||
#[serde(rename = "warpify_prompt")]
|
||||
ShouldPromptWarpification {
|
||||
#[serde(skip)]
|
||||
command: String,
|
||||
#[serde(skip)]
|
||||
host: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Determines whether a host could be warpified.
|
||||
pub fn evaluate_warpify_ssh_host(
|
||||
command: &str,
|
||||
ssh_host: Option<&str>,
|
||||
shell_family: ShellFamily,
|
||||
warpify_settings: &WarpifySettings,
|
||||
) -> SshInteractiveSessionDetected {
|
||||
let should_prompt_ssh_tmux_wrapper = *warpify_settings.enable_ssh_warpification.value()
|
||||
&& *warpify_settings.use_ssh_tmux_wrapper.value();
|
||||
let matches_subshell = warpify_settings.is_denylisted_subshell_command(command)
|
||||
|| warpify_settings.is_compatible_subshell_command(command, shell_family);
|
||||
if !should_prompt_ssh_tmux_wrapper
|
||||
|| matches_subshell
|
||||
|| !FeatureFlag::SSHTmuxWrapper.is_enabled()
|
||||
{
|
||||
return SshInteractiveSessionDetected::FeatureDisabled;
|
||||
}
|
||||
|
||||
if let Some(ssh_host) = ssh_host {
|
||||
if warpify_settings.is_ssh_host_denylisted(ssh_host) {
|
||||
return SshInteractiveSessionDetected::HostDenylisted;
|
||||
}
|
||||
}
|
||||
|
||||
SshInteractiveSessionDetected::ShouldPromptWarpification {
|
||||
host: ssh_host.map(|host| host.to_owned()),
|
||||
command: command.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
use std::path::Path;
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
/// Converts a multiline bash or zsh script to one line by turning newlines into semicolons or
|
||||
/// deleting them, as appropriate.
|
||||
///
|
||||
/// Extra semicolons are a syntax error in bash, so this is careful to avoid adding them except
|
||||
/// where necessary.
|
||||
///
|
||||
/// This function exists because there's a strange macOS ssh server bug where sending a lot of data
|
||||
/// containing newlines to a shell results in data corruption.
|
||||
pub fn convert_script_to_one_line(script: &str) -> String {
|
||||
lazy_static! {
|
||||
static ref EXTRA_SPACES_REGEX: Regex = Regex::new(r"\n+\s*").expect("invalid regex");
|
||||
static ref NO_SEMICOLON_REGEX: Regex =
|
||||
Regex::new("(; ?|\\{|do|then|else|in)\n").expect("invalid regex");
|
||||
static ref REMOVE_COMMENTS_REGEX: Regex = Regex::new(r"(?m)^ *#.*").expect("invalid regex");
|
||||
static ref REMOVE_LEADING_NEWLINES: Regex = Regex::new(r"^\n*").expect("invalid regex");
|
||||
};
|
||||
let script = REMOVE_COMMENTS_REGEX.replace_all(script, "");
|
||||
let script = REMOVE_LEADING_NEWLINES.replace_all(&script, "");
|
||||
let script = EXTRA_SPACES_REGEX.replace_all(&script, "\n");
|
||||
let script = NO_SEMICOLON_REGEX.replace_all(&script, "$1 ");
|
||||
let mut script = script.replace('\n', ";");
|
||||
script.push('\n');
|
||||
script
|
||||
}
|
||||
|
||||
pub enum SshLoginState {
|
||||
LastLogin,
|
||||
NonSshOutput,
|
||||
Authenticating,
|
||||
PromptDetected,
|
||||
}
|
||||
|
||||
/// Reads the contents of the output grid to determine SSH login state. Returns [SshLoginState::LastLogin] if
|
||||
/// "Last login:" is detected in the output. Returns [SshLoginState::NonSshOutput] if certain keywords
|
||||
/// known to be a part of ssh login prompts are found in the current last line of command output. The
|
||||
/// "password" and "Password" are for password authentication. "passphrase" is intended to cover authentication
|
||||
/// by public key. And "yes/no" relates to trust-on-first-use prompts for host-based authentication.
|
||||
pub fn check_ssh_login_state(block_output: &str) -> SshLoginState {
|
||||
lazy_static! {
|
||||
// Common final prompt characters followed by a space.
|
||||
static ref PROMPT_REGEX: Regex = Regex::new(r"[$#%>❯│⟫»▶λ→] $").expect("invalid regex");
|
||||
};
|
||||
|
||||
let mut last_line = None;
|
||||
|
||||
for line in block_output.lines() {
|
||||
if line.starts_with("Last login:") {
|
||||
return SshLoginState::LastLogin;
|
||||
}
|
||||
// With an iterator, there's no way to know if it's the last element so
|
||||
// we overwrite last_line at each iteration.
|
||||
last_line = Some(line);
|
||||
}
|
||||
|
||||
last_line.map_or(SshLoginState::Authenticating, |line| {
|
||||
if line.contains("password")
|
||||
|| line.contains("Password")
|
||||
|| line.contains("passphrase")
|
||||
|| line.contains("yes/no")
|
||||
|| line.contains("Please type")
|
||||
|| line.contains("'yes'")
|
||||
|| line.contains("Confirm user presence")
|
||||
|| line.starts_with("Enter ")
|
||||
|| line.starts_with("Allow ")
|
||||
{
|
||||
SshLoginState::Authenticating
|
||||
} else if PROMPT_REGEX.is_match(line) {
|
||||
SshLoginState::PromptDetected
|
||||
} else {
|
||||
SshLoginState::NonSshOutput
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Represents the parsed components of an interactive SSH command.
|
||||
/// For some [`SshWarpifyCommand`]s, we do not support parsing
|
||||
/// a host or port In these cases, we can still parse to a valid
|
||||
/// empty `InteractiveSshCommand` to indicate that we did
|
||||
/// successfully detect an interactive SSH command.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct InteractiveSshCommand {
|
||||
pub host: Option<String>,
|
||||
pub port: Option<String>,
|
||||
}
|
||||
|
||||
impl InteractiveSshCommand {
|
||||
/// Parses ssh commands of the form `ssh ...`.
|
||||
/// Only returns an `InteractiveSshCommand` if we determine the command is interactive.
|
||||
fn parse_ssh_command(command: &str) -> Option<InteractiveSshCommand> {
|
||||
let command = if let Some(suffix) = command.strip_prefix("command ") {
|
||||
suffix
|
||||
} else {
|
||||
command
|
||||
};
|
||||
let tokens = parse_ssh_command_tokens(command)?;
|
||||
let mut host: Option<String> = None;
|
||||
let mut port: Option<String> = None;
|
||||
|
||||
let mut i = 1;
|
||||
while i < tokens.len() {
|
||||
match tokens[i].as_str() {
|
||||
// -T or -W imply a non-interactive session.
|
||||
"-T" | "-W" => return None,
|
||||
|
||||
"-p" => {
|
||||
i += 1;
|
||||
if i < tokens.len() {
|
||||
port = Some(tokens[i].clone());
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// SSH option that doesn't change interactivity and require an argument: Skip the next item.
|
||||
"-B" | "-b" | "-c" | "-D" | "-E" | "-e" | "-F" | "-I" | "-i" | "-J" | "-L"
|
||||
| "-l" | "-m" | "-O" | "-o" | "-P" | "-Q" | "-R" | "-S" | "-w" => {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// SSH option(s) that don't change interactivity.
|
||||
arg if arg.starts_with('-') => {}
|
||||
|
||||
// Otherwise, it's a positional argument (e.g., hostname, command to run)
|
||||
pos_arg => {
|
||||
// If we detect mutliple positional args, there's some type of unknown command formulation.
|
||||
if host.is_some() {
|
||||
return None;
|
||||
}
|
||||
host = Some(pos_arg.to_string());
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Some(InteractiveSshCommand { host, port })
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SshLikeCommand {
|
||||
Gcloud,
|
||||
ElasticBeanstalk,
|
||||
DigitalOceanDroplet,
|
||||
}
|
||||
|
||||
/// TMUX SSH Warpification can be triggered by any command that
|
||||
/// we determine to be an interactive SSH command. This enum
|
||||
/// represents the different types of SSH commands we support
|
||||
/// for TMUX Warpification. `Ssh` means a literal `ssh` command,
|
||||
/// where all other commands are categorized as SSH-like commands.
|
||||
pub enum SshWarpifyCommand {
|
||||
Ssh,
|
||||
SshLike(SshLikeCommand),
|
||||
}
|
||||
|
||||
impl SshWarpifyCommand {
|
||||
/// Not a literal `ssh` command, but another command that starts an interactive SSH
|
||||
/// session that we can Warpify with TMUX.
|
||||
pub fn is_ssh_like_command(&self) -> bool {
|
||||
matches!(self, SshWarpifyCommand::SshLike(_))
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref INTERACTIVE_SSH: Regex = Regex::new(r"^ssh\s+").expect("interactive SSH regex invalid");
|
||||
|
||||
/// Matches "gcloud compute ssh" for connecting to GCP VMs.
|
||||
static ref GCLOUD_REGEX: Regex = Regex::new(r"^gcloud\s+compute\s+ssh\s.+").expect("gcloud SSH regex invalid");
|
||||
|
||||
/// Matches "eb ssh" for connecting to AWS Elastic Beanstalk VMs.
|
||||
static ref ELASTIC_BEANSTALK_REGEX: Regex = Regex::new(r"^eb\s+ssh\s.+").expect("elastic beanstalk SSH regex invalid");
|
||||
|
||||
/// Matches "doctl compute ssh" for connecting to a digital ocean droplet.
|
||||
static ref DIGITAL_OCEAN_DROPLET_REGEX: Regex = Regex::new(r"^doctl\s+compute\s+ssh\s.+").expect("digital ocean SSH regex invalid");
|
||||
}
|
||||
|
||||
impl SshWarpifyCommand {
|
||||
pub fn matches(command: &str) -> Option<SshWarpifyCommand> {
|
||||
let command = if let Some(suffix) = command.strip_prefix("command ") {
|
||||
suffix
|
||||
} else {
|
||||
command
|
||||
};
|
||||
if INTERACTIVE_SSH.is_match(command) {
|
||||
Some(SshWarpifyCommand::Ssh)
|
||||
} else if GCLOUD_REGEX.is_match(command) {
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::Gcloud))
|
||||
} else if ELASTIC_BEANSTALK_REGEX.is_match(command) {
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::ElasticBeanstalk))
|
||||
} else if DIGITAL_OCEAN_DROPLET_REGEX.is_match(command) {
|
||||
Some(SshWarpifyCommand::SshLike(
|
||||
SshLikeCommand::DigitalOceanDroplet,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_interactive_ssh_command(command: &str) -> Option<InteractiveSshCommand> {
|
||||
match SshWarpifyCommand::matches(command) {
|
||||
Some(SshWarpifyCommand::Ssh) => InteractiveSshCommand::parse_ssh_command(command),
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::Gcloud)) => {
|
||||
Some(InteractiveSshCommand::default())
|
||||
}
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::ElasticBeanstalk)) => {
|
||||
Some(InteractiveSshCommand::default())
|
||||
}
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::DigitalOceanDroplet)) => {
|
||||
Some(InteractiveSshCommand::default())
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ssh_command_tokens(command: &str) -> Option<Vec<String>> {
|
||||
let Ok(tokens) = shell_words::split(command) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Cases: "", "ls", "ssh-add-key"
|
||||
if tokens.is_empty() || tokens[0] != "ssh" {
|
||||
return None;
|
||||
}
|
||||
Some(tokens)
|
||||
}
|
||||
|
||||
/// Creates an sftp command that copies a given local file into the pwd in the warpified ssh session.
|
||||
pub fn transfer_file_sftp_command(
|
||||
local_file_path: String,
|
||||
ssh_host: String,
|
||||
ssh_port: Option<String>,
|
||||
pwd: Option<String>,
|
||||
) -> Option<String> {
|
||||
// "sftp "
|
||||
let mut command = String::from("sftp ");
|
||||
|
||||
// "sftp -P 2222"
|
||||
if let Some(port) = ssh_port {
|
||||
command += &format!("-P {port} ");
|
||||
}
|
||||
|
||||
// "sftp -P 2222 sshuser@127.0.0.1 <<< "put "
|
||||
command += &ssh_host;
|
||||
command += " <<< \"put ";
|
||||
|
||||
// "sftp -P 2222 sshuser@127.0.0.1 <<< "put -r"
|
||||
let is_dir = Path::new(&local_file_path)
|
||||
.metadata()
|
||||
.is_ok_and(|m| m.is_dir());
|
||||
if is_dir {
|
||||
command += "-r "
|
||||
}
|
||||
|
||||
// "sftp -P 2222 sshuser@127.0.0.1 <<< "put -r \"path/to/local/file\""
|
||||
command += &format!("\\\"{}\\\"", &local_file_path);
|
||||
|
||||
// "sftp -P 2222 sshuser@127.0.0.1 <<< "put -r path/to/local/file pwd/on/remote"
|
||||
if let Some(pwd) = pwd {
|
||||
command += " ";
|
||||
command += &format!("\\\"{}\\\"", &pwd);
|
||||
}
|
||||
|
||||
// "sftp -P 2222 sshuser@127.0.0.1 <<< "put -r path/to/local/file pwd/on/remote""
|
||||
command += "\"";
|
||||
|
||||
Some(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ssh_gcloud_ssh_parsing() {
|
||||
assert!(parse_interactive_ssh_command("gcloud").is_none());
|
||||
assert!(parse_interactive_ssh_command("gcloud compute").is_none());
|
||||
assert!(parse_interactive_ssh_command("gcloud compute ss").is_none());
|
||||
assert!(parse_interactive_ssh_command("gcloud compute ssh").is_none());
|
||||
assert!(parse_interactive_ssh_command("command gcloud compute ssh").is_none());
|
||||
|
||||
assert!(
|
||||
parse_interactive_ssh_command("command gcloud compute ssh --zone us-west1-a").is_some()
|
||||
);
|
||||
assert!(parse_interactive_ssh_command("gcloud compute ssh --zone us-west1-a").is_some());
|
||||
assert!(
|
||||
parse_interactive_ssh_command("gcloud compute ssh --zone us-west1-a my-instance")
|
||||
.is_some()
|
||||
);
|
||||
assert!(parse_interactive_ssh_command(
|
||||
"gcloud compute ssh --zone us-west1-a my-instance --project my-project"
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_elastic_beanstalk_parsing() {
|
||||
assert!(parse_interactive_ssh_command("eb").is_none());
|
||||
assert!(parse_interactive_ssh_command("eb ss").is_none());
|
||||
assert!(parse_interactive_ssh_command("eb ssh").is_none());
|
||||
assert!(parse_interactive_ssh_command("command eb ssh").is_none());
|
||||
|
||||
assert!(parse_interactive_ssh_command("command eb ssh --profile my-profile").is_some());
|
||||
assert!(parse_interactive_ssh_command("eb ssh --profile my-profile").is_some());
|
||||
assert!(parse_interactive_ssh_command("eb ssh --profile my-profile my-env").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_digital_ocean_droplet_parsing() {
|
||||
assert!(parse_interactive_ssh_command("doctl").is_none());
|
||||
assert!(parse_interactive_ssh_command("doctl compute").is_none());
|
||||
assert!(parse_interactive_ssh_command("doctl compute ss").is_none());
|
||||
assert!(parse_interactive_ssh_command("doctl compute ssh").is_none());
|
||||
assert!(parse_interactive_ssh_command("command doctl compute ssh").is_none());
|
||||
|
||||
assert!(parse_interactive_ssh_command("command doctl compute ssh --region nyc1").is_some());
|
||||
assert!(parse_interactive_ssh_command("doctl compute ssh --region nyc1").is_some());
|
||||
assert!(
|
||||
parse_interactive_ssh_command("doctl compute ssh --region nyc1 my-droplet").is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that commands resulting from shell alias expansion are correctly
|
||||
/// detected as interactive SSH commands. When a user types an alias (e.g.
|
||||
/// `myssh`), the terminal view expands it to the alias value before passing
|
||||
/// it to `parse_interactive_ssh_command`. These tests cover representative
|
||||
/// expanded forms.
|
||||
#[test]
|
||||
fn ssh_alias_expanded_commands() {
|
||||
// Simple alias: alias myssh='ssh user@host'
|
||||
assert_eq!(
|
||||
parse_interactive_ssh_command("ssh user@host").unwrap().host,
|
||||
Some("user@host".to_string())
|
||||
);
|
||||
|
||||
// Alias with key and user: alias company1='ssh -i /path/to/key user@server'
|
||||
assert_eq!(
|
||||
parse_interactive_ssh_command("ssh -i /path/to/key user@server")
|
||||
.unwrap()
|
||||
.host,
|
||||
Some("user@server".to_string())
|
||||
);
|
||||
|
||||
// Alias with extra args appended by the user: alias myssh='ssh -i key'
|
||||
// then the user types `myssh user@host` which expands to `ssh -i key user@host`
|
||||
assert_eq!(
|
||||
parse_interactive_ssh_command("ssh -i key user@host")
|
||||
.unwrap()
|
||||
.host,
|
||||
Some("user@host".to_string())
|
||||
);
|
||||
|
||||
// Alias that isn't SSH should not match
|
||||
assert!(parse_interactive_ssh_command("ls -la").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_interactive_shell_parsing() {
|
||||
assert!(parse_interactive_ssh_command("").is_none());
|
||||
assert!(parse_interactive_ssh_command("ls").is_none());
|
||||
assert!(parse_interactive_ssh_command("ssh-add-key").is_none());
|
||||
|
||||
// Basic interactive command
|
||||
assert!(
|
||||
parse_interactive_ssh_command("ssh localhost").unwrap().host
|
||||
== Some("localhost".to_string())
|
||||
);
|
||||
assert!(
|
||||
parse_interactive_ssh_command("command ssh localhost")
|
||||
.unwrap()
|
||||
.host
|
||||
== Some("localhost".to_string())
|
||||
);
|
||||
assert!(
|
||||
parse_interactive_ssh_command("ssh root@127.14.80.1 -p 2222")
|
||||
.unwrap()
|
||||
.host
|
||||
== Some("root@127.14.80.1".to_string())
|
||||
);
|
||||
assert!(
|
||||
parse_interactive_ssh_command("ssh -4vw root@127.14.80.1 -p 2222")
|
||||
.unwrap()
|
||||
.host
|
||||
== Some("root@127.14.80.1".to_string())
|
||||
);
|
||||
|
||||
// Commands with -T or -W, which are non-interactive
|
||||
assert!(parse_interactive_ssh_command("ssh -T user@host").is_none());
|
||||
assert!(parse_interactive_ssh_command("ssh -v user@host -W localhost:22").is_none());
|
||||
assert!(
|
||||
parse_interactive_ssh_command("ssh -o IdentityFile=/etc/file -T user@host").is_none()
|
||||
);
|
||||
|
||||
// Commands with multiple positional arguments, implying non-interactive
|
||||
assert!(parse_interactive_ssh_command("ssh user@host ls").is_none());
|
||||
assert!(parse_interactive_ssh_command("ssh user@host echo 'Hello, World!'").is_none());
|
||||
|
||||
// Weird spacing and shell characters shouldn't matter
|
||||
assert!(
|
||||
parse_interactive_ssh_command("ssh user@host")
|
||||
.unwrap()
|
||||
.host
|
||||
== Some("user@host".to_string())
|
||||
);
|
||||
assert!(
|
||||
parse_interactive_ssh_command("ssh -4 -- localhost")
|
||||
.unwrap()
|
||||
.host
|
||||
== Some("localhost".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use asset_macro::bundled_asset;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use warp_core::ui::theme::WarpTheme;
|
||||
use warpui::assets::asset_cache::{AssetCache, AssetState};
|
||||
|
||||
use crate::ai::blocklist::inline_action::requested_action::RenderableAction;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::warpify;
|
||||
use crate::terminal::warpify::render::SSH_DOCS_URL;
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
use warpui::elements::{HighlightedHyperlink, Hoverable, Icon, MouseStateHandle};
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::AppContext;
|
||||
use warpui::{
|
||||
elements::{Border, Container, CrossAxisAlignment, Flex, ParentElement},
|
||||
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SshWarpifyBlockEvent {
|
||||
WarpifySession,
|
||||
Cancel,
|
||||
Interrupt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum SshWarpifyBlockAction {
|
||||
Interrupt,
|
||||
Focus,
|
||||
}
|
||||
|
||||
pub struct SshWarpifyBlock {
|
||||
block_mouse_state: MouseStateHandle,
|
||||
ssh_command: String,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"ctrl-c",
|
||||
SshWarpifyBlockAction::Interrupt,
|
||||
id!(SshWarpifyBlock::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
impl SshWarpifyBlock {
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new(ssh_command: String) -> Self {
|
||||
Self {
|
||||
block_mouse_state: Default::default(),
|
||||
ssh_command,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SshWarpifyBlock {
|
||||
type Event = SshWarpifyBlockEvent;
|
||||
}
|
||||
|
||||
impl SshWarpifyBlock {
|
||||
fn render_title_ui(&self, theme: &WarpTheme, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let icon = Icon::new(UiIcon::Warp.into(), theme.active_ui_detail());
|
||||
warpify::render::header_row("Warpifying SSH Session...", icon, theme, appearance)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warpify_description(
|
||||
app: &AppContext,
|
||||
hyperlink_index: &HighlightedHyperlink,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let description = FormattedText::new(vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(
|
||||
"Bring Warp's features to your remote session. Blocks, full text editing, auto-complete, Oz, and more. "
|
||||
),
|
||||
FormattedTextFragment::hyperlink("Learn more", SSH_DOCS_URL),
|
||||
])]);
|
||||
warpify::render::build_description_row(description, theme, appearance, hyperlink_index.clone())
|
||||
.with_hyperlink_font_color(appearance.theme().accent().into_solid())
|
||||
.register_default_click_handlers(|url, _, ctx| {
|
||||
ctx.open_url(&url.url);
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
impl View for SshWarpifyBlock {
|
||||
fn ui_name() -> &'static str {
|
||||
"SshWarpifyBlock"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
content.add_child(self.render_title_ui(theme, appearance));
|
||||
|
||||
content.add_child(
|
||||
Container::new(
|
||||
RenderableAction::new(&self.ssh_command, app)
|
||||
.with_background_color(theme.background().into_solid())
|
||||
.render(app)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Hoverable::new(self.block_mouse_state.clone(), |_| {
|
||||
Container::new(content.finish())
|
||||
.with_padding_top(10.)
|
||||
.with_background(theme.foreground().with_opacity(10))
|
||||
.with_border(Border::top(1.).with_border_fill(theme.outline()))
|
||||
.finish()
|
||||
})
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SshWarpifyBlockAction::Focus);
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for SshWarpifyBlock {
|
||||
type Action = SshWarpifyBlockAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SshWarpifyBlockAction::Interrupt => {
|
||||
ctx.emit(SshWarpifyBlockEvent::Interrupt);
|
||||
}
|
||||
SshWarpifyBlockAction::Focus => {
|
||||
self.focus(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the begin_warpify_ssh_session script into a string.
|
||||
pub fn begin_warpify_ssh_session_command(app: &AppContext) -> String {
|
||||
let asset = bundled_asset!("bootstrap/unknown_init_subshell.sh");
|
||||
|
||||
match AssetCache::as_ref(app).load_asset::<String>(asset) {
|
||||
AssetState::Loaded { data } => data.to_string().replace("HOOK_NAME", "InitSsh"),
|
||||
_ => panic!("ssh begin warpify script should be available as a string"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the warpify_ssh_session script into a string.
|
||||
pub fn warpify_ssh_session_command(
|
||||
uname: &str,
|
||||
shell_type: ShellType,
|
||||
app: &AppContext,
|
||||
) -> Option<String> {
|
||||
let asset = match (uname, shell_type) {
|
||||
// Mac scripts must be less than 1020 characters due to macOS 15+ pty issue
|
||||
("Darwin", ShellType::Zsh | ShellType::Bash) => {
|
||||
bundled_asset!("ssh/bash_zsh/warpify_ssh_session_mac.sh")
|
||||
}
|
||||
// Mac scripts must be less than 1020 characters due to macOS 15+ pty issue
|
||||
("Darwin", ShellType::Fish) => bundled_asset!("ssh/fish/warpify_ssh_session_mac.sh"),
|
||||
(_, ShellType::Zsh | ShellType::Bash) => {
|
||||
bundled_asset!("ssh/bash_zsh/warpify_ssh_session.sh")
|
||||
}
|
||||
(_, ShellType::Fish) => bundled_asset!("ssh/fish/warpify_ssh_session.sh"),
|
||||
// PowerShell is not supported yet.
|
||||
(_, ShellType::PowerShell) => return None,
|
||||
};
|
||||
|
||||
// Todo(Jack): look into avoiding an allocation here.
|
||||
match AssetCache::as_ref(app).load_asset::<String>(asset) {
|
||||
AssetState::Loaded { data } => Some(data.to_string()),
|
||||
_ => panic!("ssh warpify script should be available as a string"),
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "warpify_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,88 @@
|
||||
use warpui::{assets::asset_cache::AssetSource, App};
|
||||
|
||||
use crate::{
|
||||
terminal::ssh::util::convert_script_to_one_line,
|
||||
test_util::settings::initialize_settings_for_tests, Assets,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
}
|
||||
|
||||
/// We write a couple extra bytes at the beginning of a script to clear the line, so we limit the
|
||||
/// script to 1020 bytes here.
|
||||
fn assert_script_is_short_enough_mac(script: &str, script_name: &str, convert_to_one_line: bool) {
|
||||
let script = if convert_to_one_line {
|
||||
convert_script_to_one_line(script)
|
||||
} else {
|
||||
script.to_string()
|
||||
};
|
||||
assert!(
|
||||
script.len() <= 1020,
|
||||
"{} script too long: {} bytes",
|
||||
script_name,
|
||||
script.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn get_script(asset_source: AssetSource, ctx: &AppContext) -> String {
|
||||
match AssetCache::as_ref(ctx).load_asset::<String>(asset_source) {
|
||||
AssetState::Loaded { data } => data.to_string(),
|
||||
_ => panic!("install tmux script should be available as a string"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "TODO(CORE-3626)")]
|
||||
#[test]
|
||||
/// See [assert_script_is_short_enough_mac] for more information.
|
||||
fn test_mac_warpification_script_size() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
app.read(|ctx| {
|
||||
assert_script_is_short_enough_mac(
|
||||
&begin_warpify_ssh_session_command(ctx),
|
||||
"unknown_init_subshell.sh",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_script_is_short_enough_mac(
|
||||
&get_script(
|
||||
bundled_asset!("ssh/bash_zsh/install_tmux_and_warpify_brew.sh"),
|
||||
ctx,
|
||||
),
|
||||
"install_tmux_and_warpify_brew.sh",
|
||||
false,
|
||||
);
|
||||
assert_script_is_short_enough_mac(
|
||||
&get_script(
|
||||
bundled_asset!("ssh/fish/install_tmux_and_warpify_brew.sh"),
|
||||
ctx,
|
||||
),
|
||||
"fish/install_tmux_and_warpify_brew.sh",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_script_is_short_enough_mac(
|
||||
&warpify_ssh_session_command("Darwin", ShellType::Zsh, ctx)
|
||||
.expect("Should get Darwin zsh script"),
|
||||
"zsh warpify",
|
||||
true,
|
||||
);
|
||||
assert_script_is_short_enough_mac(
|
||||
&warpify_ssh_session_command("Darwin", ShellType::Bash, ctx)
|
||||
.expect("Should get Darwin bash script"),
|
||||
"bash warpify",
|
||||
true,
|
||||
);
|
||||
assert_script_is_short_enough_mac(
|
||||
&warpify_ssh_session_command("Darwin", ShellType::Fish, ctx)
|
||||
.expect("Should get Darwin fish script"),
|
||||
"fish warpify",
|
||||
true,
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user