remote_connections.rs

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