Files
galaxy/app/src/terminal/ssh/install_tmux.rs
T

604 lines
21 KiB
Rust

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 galaxy_core::ui::theme::GalaxyTheme;
use galaxyui::elements::{
FormattedTextElement, HighlightedHyperlink, Hoverable, Icon, MainAxisAlignment, MainAxisSize,
MouseStateHandle,
};
use galaxyui::keymap::FixedBinding;
use galaxyui::ui_components::toggle_menu::ToggleMenuStateHandle;
use galaxyui::{
elements::{Border, Container, CrossAxisAlignment, Flex, ParentElement},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use galaxyui::{BlurContext, FocusContext};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
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 galaxyui::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: &GalaxyTheme,
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 Wormhole your SSH session, a more recent version of tmux (>=3.0) must be installed. "
} else {
"In order to Wormhole 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 galaxyui::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> {
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
}