remote_connections.rs

  1use std::{path::PathBuf, sync::Arc};
  2
  3use anyhow::{Context as _, Result};
  4use askpass::EncryptedPassword;
  5use auto_update::AutoUpdater;
  6use editor::Editor;
  7use extension_host::ExtensionStore;
  8use futures::channel::oneshot;
  9use gpui::{
 10    AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
 11    ParentElement as _, PromptLevel, Render, SemanticVersion, SharedString, Task,
 12    TextStyleRefinement, WeakEntity,
 13};
 14
 15use language::CursorShape;
 16use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 17use release_channel::ReleaseChannel;
 18use remote::{
 19    ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform,
 20    SshConnectionOptions,
 21};
 22pub use settings::SshConnection;
 23use settings::{ExtendingVec, Settings, WslConnection};
 24use theme::ThemeSettings;
 25use ui::{
 26    ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
 27    IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
 28};
 29use workspace::{AppState, ModalView, Workspace};
 30
 31pub struct SshSettings {
 32    pub ssh_connections: ExtendingVec<SshConnection>,
 33    pub wsl_connections: ExtendingVec<WslConnection>,
 34    /// Whether to read ~/.ssh/config for ssh connection sources.
 35    pub read_ssh_config: bool,
 36}
 37
 38impl SshSettings {
 39    pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
 40        self.ssh_connections.clone().0.into_iter()
 41    }
 42
 43    pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
 44        self.wsl_connections.clone().0.into_iter()
 45    }
 46
 47    pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
 48        for conn in self.ssh_connections() {
 49            if conn.host == options.host
 50                && conn.username == options.username
 51                && conn.port == options.port
 52            {
 53                options.nickname = conn.nickname;
 54                options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
 55                options.args = Some(conn.args);
 56                options.port_forwards = conn.port_forwards;
 57                break;
 58            }
 59        }
 60    }
 61
 62    pub fn connection_options_for(
 63        &self,
 64        host: String,
 65        port: Option<u16>,
 66        username: Option<String>,
 67    ) -> SshConnectionOptions {
 68        let mut options = SshConnectionOptions {
 69            host,
 70            port,
 71            username,
 72            ..Default::default()
 73        };
 74        self.fill_connection_options_from_settings(&mut options);
 75        options
 76    }
 77}
 78
 79#[derive(Clone, PartialEq)]
 80pub enum Connection {
 81    Ssh(SshConnection),
 82    Wsl(WslConnection),
 83}
 84
 85impl From<Connection> for RemoteConnectionOptions {
 86    fn from(val: Connection) -> Self {
 87        match val {
 88            Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
 89            Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
 90        }
 91    }
 92}
 93
 94impl From<SshConnection> for Connection {
 95    fn from(val: SshConnection) -> Self {
 96        Connection::Ssh(val)
 97    }
 98}
 99
100impl From<WslConnection> for Connection {
101    fn from(val: WslConnection) -> Self {
102        Connection::Wsl(val)
103    }
104}
105
106impl Settings for SshSettings {
107    fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self {
108        let remote = &content.remote;
109        Self {
110            ssh_connections: remote.ssh_connections.clone().unwrap_or_default().into(),
111            wsl_connections: remote.wsl_connections.clone().unwrap_or_default().into(),
112            read_ssh_config: remote.read_ssh_config.unwrap(),
113        }
114    }
115}
116
117pub struct RemoteConnectionPrompt {
118    connection_string: SharedString,
119    nickname: Option<SharedString>,
120    is_wsl: bool,
121    status_message: Option<SharedString>,
122    prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
123    cancellation: Option<oneshot::Sender<()>>,
124    editor: Entity<Editor>,
125}
126
127impl Drop for RemoteConnectionPrompt {
128    fn drop(&mut self) {
129        if let Some(cancel) = self.cancellation.take() {
130            cancel.send(()).ok();
131        }
132    }
133}
134
135pub struct RemoteConnectionModal {
136    pub(crate) prompt: Entity<RemoteConnectionPrompt>,
137    paths: Vec<PathBuf>,
138    finished: bool,
139}
140
141impl RemoteConnectionPrompt {
142    pub(crate) fn new(
143        connection_string: String,
144        nickname: Option<String>,
145        is_wsl: bool,
146        window: &mut Window,
147        cx: &mut Context<Self>,
148    ) -> Self {
149        Self {
150            connection_string: connection_string.into(),
151            nickname: nickname.map(|nickname| nickname.into()),
152            is_wsl,
153            editor: cx.new(|cx| Editor::single_line(window, cx)),
154            status_message: None,
155            cancellation: None,
156            prompt: None,
157        }
158    }
159
160    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
161        self.cancellation = Some(tx);
162    }
163
164    fn set_prompt(
165        &mut self,
166        prompt: String,
167        tx: oneshot::Sender<EncryptedPassword>,
168        window: &mut Window,
169        cx: &mut Context<Self>,
170    ) {
171        let theme = ThemeSettings::get_global(cx);
172
173        let refinement = TextStyleRefinement {
174            font_family: Some(theme.buffer_font.family.clone()),
175            font_features: Some(FontFeatures::disable_ligatures()),
176            font_size: Some(theme.buffer_font_size(cx).into()),
177            color: Some(cx.theme().colors().editor_foreground),
178            background_color: Some(gpui::transparent_black()),
179            ..Default::default()
180        };
181
182        self.editor.update(cx, |editor, cx| {
183            if prompt.contains("yes/no") {
184                editor.set_masked(false, cx);
185            } else {
186                editor.set_masked(true, cx);
187            }
188            editor.set_text_style_refinement(refinement);
189            editor.set_cursor_shape(CursorShape::Block, cx);
190        });
191
192        let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
193        self.prompt = Some((markdown, tx));
194        self.status_message.take();
195        window.focus(&self.editor.focus_handle(cx));
196        cx.notify();
197    }
198
199    pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
200        self.status_message = status.map(|s| s.into());
201        cx.notify();
202    }
203
204    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
205        if let Some((_, tx)) = self.prompt.take() {
206            self.status_message = Some("Connecting".into());
207
208            self.editor.update(cx, |editor, cx| {
209                let pw = editor.text(cx);
210                if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
211                    tx.send(secure).ok();
212                }
213                editor.clear(window, cx);
214            });
215        }
216    }
217}
218
219impl Render for RemoteConnectionPrompt {
220    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
221        let theme = ThemeSettings::get_global(cx);
222
223        let mut text_style = window.text_style();
224        let refinement = TextStyleRefinement {
225            font_family: Some(theme.buffer_font.family.clone()),
226            font_features: Some(FontFeatures::disable_ligatures()),
227            font_size: Some(theme.buffer_font_size(cx).into()),
228            color: Some(cx.theme().colors().editor_foreground),
229            background_color: Some(gpui::transparent_black()),
230            ..Default::default()
231        };
232
233        text_style.refine(&refinement);
234        let markdown_style = MarkdownStyle {
235            base_text_style: text_style,
236            selection_background_color: cx.theme().colors().element_selection_background,
237            ..Default::default()
238        };
239
240        v_flex()
241            .key_context("PasswordPrompt")
242            .py_2()
243            .px_3()
244            .size_full()
245            .text_buffer(cx)
246            .when_some(self.status_message.clone(), |el, status_message| {
247                el.child(
248                    h_flex()
249                        .gap_1()
250                        .child(
251                            Icon::new(IconName::ArrowCircle)
252                                .size(IconSize::Medium)
253                                .with_rotate_animation(2),
254                        )
255                        .child(
256                            div()
257                                .text_ellipsis()
258                                .overflow_x_hidden()
259                                .child(format!("{}", status_message)),
260                        ),
261                )
262            })
263            .when_some(self.prompt.as_ref(), |el, prompt| {
264                el.child(
265                    div()
266                        .size_full()
267                        .overflow_hidden()
268                        .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
269                        .child(self.editor.clone()),
270                )
271                .when(window.capslock().on, |el| {
272                    el.child(Label::new("⚠️ ⇪ is on"))
273                })
274            })
275    }
276}
277
278impl RemoteConnectionModal {
279    pub(crate) fn new(
280        connection_options: &RemoteConnectionOptions,
281        paths: Vec<PathBuf>,
282        window: &mut Window,
283        cx: &mut Context<Self>,
284    ) -> Self {
285        let (connection_string, nickname, is_wsl) = match connection_options {
286            RemoteConnectionOptions::Ssh(options) => {
287                (options.connection_string(), options.nickname.clone(), false)
288            }
289            RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true),
290        };
291        Self {
292            prompt: cx.new(|cx| {
293                RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx)
294            }),
295            finished: false,
296            paths,
297        }
298    }
299
300    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
301        self.prompt
302            .update(cx, |prompt, cx| prompt.confirm(window, cx))
303    }
304
305    pub fn finished(&mut self, cx: &mut Context<Self>) {
306        self.finished = true;
307        cx.emit(DismissEvent);
308    }
309
310    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
311        if let Some(tx) = self
312            .prompt
313            .update(cx, |prompt, _cx| prompt.cancellation.take())
314        {
315            tx.send(()).ok();
316        }
317        self.finished(cx);
318    }
319}
320
321pub(crate) struct SshConnectionHeader {
322    pub(crate) connection_string: SharedString,
323    pub(crate) paths: Vec<PathBuf>,
324    pub(crate) nickname: Option<SharedString>,
325    pub(crate) is_wsl: bool,
326}
327
328impl RenderOnce for SshConnectionHeader {
329    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
330        let theme = cx.theme();
331
332        let mut header_color = theme.colors().text;
333        header_color.fade_out(0.96);
334
335        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
336            (nickname, Some(format!("({})", self.connection_string)))
337        } else {
338            (self.connection_string, None)
339        };
340
341        let icon = match self.is_wsl {
342            true => IconName::Linux,
343            false => IconName::Server,
344        };
345
346        h_flex()
347            .px(DynamicSpacing::Base12.rems(cx))
348            .pt(DynamicSpacing::Base08.rems(cx))
349            .pb(DynamicSpacing::Base04.rems(cx))
350            .rounded_t_sm()
351            .w_full()
352            .gap_1p5()
353            .child(Icon::new(icon).size(IconSize::Small))
354            .child(
355                h_flex()
356                    .gap_1()
357                    .overflow_x_hidden()
358                    .child(
359                        div()
360                            .max_w_96()
361                            .overflow_x_hidden()
362                            .text_ellipsis()
363                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
364                    )
365                    .children(
366                        meta_label.map(|label| {
367                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
368                        }),
369                    )
370                    .child(div().overflow_x_hidden().text_ellipsis().children(
371                        self.paths.into_iter().map(|path| {
372                            Label::new(path.to_string_lossy().to_string())
373                                .size(LabelSize::Small)
374                                .color(Color::Muted)
375                        }),
376                    )),
377            )
378    }
379}
380
381impl Render for RemoteConnectionModal {
382    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
383        let nickname = self.prompt.read(cx).nickname.clone();
384        let connection_string = self.prompt.read(cx).connection_string.clone();
385        let is_wsl = self.prompt.read(cx).is_wsl;
386
387        let theme = cx.theme().clone();
388        let body_color = theme.colors().editor_background;
389
390        v_flex()
391            .elevation_3(cx)
392            .w(rems(34.))
393            .border_1()
394            .border_color(theme.colors().border)
395            .key_context("SshConnectionModal")
396            .track_focus(&self.focus_handle(cx))
397            .on_action(cx.listener(Self::dismiss))
398            .on_action(cx.listener(Self::confirm))
399            .child(
400                SshConnectionHeader {
401                    paths: self.paths.clone(),
402                    connection_string,
403                    nickname,
404                    is_wsl,
405                }
406                .render(window, cx),
407            )
408            .child(
409                div()
410                    .w_full()
411                    .rounded_b_lg()
412                    .bg(body_color)
413                    .border_t_1()
414                    .border_color(theme.colors().border_variant)
415                    .child(self.prompt.clone()),
416            )
417    }
418}
419
420impl Focusable for RemoteConnectionModal {
421    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
422        self.prompt.read(cx).editor.focus_handle(cx)
423    }
424}
425
426impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
427
428impl ModalView for RemoteConnectionModal {
429    fn on_before_dismiss(
430        &mut self,
431        _window: &mut Window,
432        _: &mut Context<Self>,
433    ) -> workspace::DismissDecision {
434        workspace::DismissDecision::Dismiss(self.finished)
435    }
436
437    fn fade_out_background(&self) -> bool {
438        true
439    }
440}
441
442#[derive(Clone)]
443pub struct RemoteClientDelegate {
444    window: AnyWindowHandle,
445    ui: WeakEntity<RemoteConnectionPrompt>,
446    known_password: Option<EncryptedPassword>,
447}
448
449impl remote::RemoteClientDelegate for RemoteClientDelegate {
450    fn ask_password(
451        &self,
452        prompt: String,
453        tx: oneshot::Sender<EncryptedPassword>,
454        cx: &mut AsyncApp,
455    ) {
456        let mut known_password = self.known_password.clone();
457        if let Some(password) = known_password.take() {
458            tx.send(password).ok();
459        } else {
460            self.window
461                .update(cx, |_, window, cx| {
462                    self.ui.update(cx, |modal, cx| {
463                        modal.set_prompt(prompt, tx, window, cx);
464                    })
465                })
466                .ok();
467        }
468    }
469
470    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
471        self.update_status(status, cx)
472    }
473
474    fn download_server_binary_locally(
475        &self,
476        platform: RemotePlatform,
477        release_channel: ReleaseChannel,
478        version: Option<SemanticVersion>,
479        cx: &mut AsyncApp,
480    ) -> Task<anyhow::Result<PathBuf>> {
481        cx.spawn(async move |cx| {
482            let binary_path = AutoUpdater::download_remote_server_release(
483                platform.os,
484                platform.arch,
485                release_channel,
486                version,
487                cx,
488            )
489            .await
490            .with_context(|| {
491                format!(
492                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
493                    version
494                        .map(|v| format!("{}", v))
495                        .unwrap_or("unknown".to_string()),
496                    platform.os,
497                    platform.arch,
498                )
499            })?;
500            Ok(binary_path)
501        })
502    }
503
504    fn get_download_params(
505        &self,
506        platform: RemotePlatform,
507        release_channel: ReleaseChannel,
508        version: Option<SemanticVersion>,
509        cx: &mut AsyncApp,
510    ) -> Task<Result<Option<(String, String)>>> {
511        cx.spawn(async move |cx| {
512            AutoUpdater::get_remote_server_release_url(
513                platform.os,
514                platform.arch,
515                release_channel,
516                version,
517                cx,
518            )
519            .await
520        })
521    }
522}
523
524impl RemoteClientDelegate {
525    fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
526        self.window
527            .update(cx, |_, _, cx| {
528                self.ui.update(cx, |modal, cx| {
529                    modal.set_status(status.map(|s| s.to_string()), cx);
530                })
531            })
532            .ok();
533    }
534}
535
536pub fn connect_over_ssh(
537    unique_identifier: ConnectionIdentifier,
538    connection_options: SshConnectionOptions,
539    ui: Entity<RemoteConnectionPrompt>,
540    window: &mut Window,
541    cx: &mut App,
542) -> Task<Result<Option<Entity<RemoteClient>>>> {
543    let window = window.window_handle();
544    let known_password = connection_options
545        .password
546        .as_deref()
547        .and_then(|pw| EncryptedPassword::try_from(pw).ok());
548    let (tx, rx) = oneshot::channel();
549    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
550
551    remote::RemoteClient::ssh(
552        unique_identifier,
553        connection_options,
554        rx,
555        Arc::new(RemoteClientDelegate {
556            window,
557            ui: ui.downgrade(),
558            known_password,
559        }),
560        cx,
561    )
562}
563
564pub fn connect(
565    unique_identifier: ConnectionIdentifier,
566    connection_options: RemoteConnectionOptions,
567    ui: Entity<RemoteConnectionPrompt>,
568    window: &mut Window,
569    cx: &mut App,
570) -> Task<Result<Option<Entity<RemoteClient>>>> {
571    let window = window.window_handle();
572    let known_password = match &connection_options {
573        RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
574            .password
575            .as_deref()
576            .and_then(|pw| pw.try_into().ok()),
577        _ => None,
578    };
579    let (tx, rx) = oneshot::channel();
580    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
581
582    remote::RemoteClient::new(
583        unique_identifier,
584        connection_options,
585        rx,
586        Arc::new(RemoteClientDelegate {
587            window,
588            ui: ui.downgrade(),
589            known_password,
590        }),
591        cx,
592    )
593}
594
595pub async fn open_remote_project(
596    connection_options: RemoteConnectionOptions,
597    paths: Vec<PathBuf>,
598    app_state: Arc<AppState>,
599    open_options: workspace::OpenOptions,
600    cx: &mut AsyncApp,
601) -> Result<()> {
602    let window = if let Some(window) = open_options.replace_window {
603        window
604    } else {
605        let workspace_position = cx
606            .update(|cx| {
607                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
608            })?
609            .await
610            .context("fetching ssh workspace position from db")?;
611
612        let mut options =
613            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
614        options.window_bounds = workspace_position.window_bounds;
615
616        cx.open_window(options, |window, cx| {
617            let project = project::Project::local(
618                app_state.client.clone(),
619                app_state.node_runtime.clone(),
620                app_state.user_store.clone(),
621                app_state.languages.clone(),
622                app_state.fs.clone(),
623                None,
624                cx,
625            );
626            cx.new(|cx| {
627                let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
628                workspace.centered_layout = workspace_position.centered_layout;
629                workspace
630            })
631        })?
632    };
633
634    loop {
635        let (cancel_tx, cancel_rx) = oneshot::channel();
636        let delegate = window.update(cx, {
637            let paths = paths.clone();
638            let connection_options = connection_options.clone();
639            move |workspace, window, cx| {
640                window.activate_window();
641                workspace.toggle_modal(window, cx, |window, cx| {
642                    RemoteConnectionModal::new(&connection_options, paths, window, cx)
643                });
644
645                let ui = workspace
646                    .active_modal::<RemoteConnectionModal>(cx)?
647                    .read(cx)
648                    .prompt
649                    .clone();
650
651                ui.update(cx, |ui, _cx| {
652                    ui.set_cancellation_tx(cancel_tx);
653                });
654
655                Some(Arc::new(RemoteClientDelegate {
656                    window: window.window_handle(),
657                    ui: ui.downgrade(),
658                    known_password: if let RemoteConnectionOptions::Ssh(options) =
659                        &connection_options
660                    {
661                        options
662                            .password
663                            .as_deref()
664                            .and_then(|pw| EncryptedPassword::try_from(pw).ok())
665                    } else {
666                        None
667                    },
668                }))
669            }
670        })?;
671
672        let Some(delegate) = delegate else { break };
673
674        let did_open_project = cx
675            .update(|cx| {
676                workspace::open_remote_project_with_new_connection(
677                    window,
678                    connection_options.clone(),
679                    cancel_rx,
680                    delegate.clone(),
681                    app_state.clone(),
682                    paths.clone(),
683                    cx,
684                )
685            })?
686            .await;
687
688        window
689            .update(cx, |workspace, _, cx| {
690                if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
691                    ui.update(cx, |modal, cx| modal.finished(cx))
692                }
693            })
694            .ok();
695
696        if let Err(e) = did_open_project {
697            log::error!("Failed to open project: {e:?}");
698            let response = window
699                .update(cx, |_, window, cx| {
700                    window.prompt(
701                        PromptLevel::Critical,
702                        match connection_options {
703                            RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
704                            RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
705                        },
706                        Some(&e.to_string()),
707                        &["Retry", "Ok"],
708                        cx,
709                    )
710                })?
711                .await;
712
713            if response == Ok(0) {
714                continue;
715            }
716        }
717
718        window
719            .update(cx, |workspace, _, cx| {
720                if let Some(client) = workspace.project().read(cx).remote_client() {
721                    ExtensionStore::global(cx)
722                        .update(cx, |store, cx| store.register_remote_client(client, cx));
723                }
724            })
725            .ok();
726
727        break;
728    }
729
730    // Already showed the error to the user
731    Ok(())
732}