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, ssh_command: String, system_install_state: Option, 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, 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, ssh_command: String, ssh_host: Option, 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) { 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.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) -> 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 { 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 { 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 { 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 { 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) { if focus_ctx.is_self_focused() { self.is_focused = true; ctx.notify(); } } fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { 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) { 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 { 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::(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 { 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::(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 { 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 { None }