Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+356
View File
@@ -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);
}
}
}
}
+605
View File
@@ -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
}
+10
View File
@@ -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);
+25
View File
@@ -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")),
}
}
}
+52
View File
@@ -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(),
}
}
+416
View File
@@ -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())
);
}
}
+187
View File
@@ -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;
+88
View File
@@ -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,
)
});
});
}