remote_connections.rs

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