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    status_message: Option<SharedString>,
196    prompt: Option<(Entity<Markdown>, oneshot::Sender<String>)>,
197    cancellation: Option<oneshot::Sender<()>>,
198    editor: Entity<Editor>,
199}
200
201impl Drop for RemoteConnectionPrompt {
202    fn drop(&mut self) {
203        if let Some(cancel) = self.cancellation.take() {
204            cancel.send(()).ok();
205        }
206    }
207}
208
209pub struct RemoteConnectionModal {
210    pub(crate) prompt: Entity<RemoteConnectionPrompt>,
211    paths: Vec<PathBuf>,
212    finished: bool,
213}
214
215impl RemoteConnectionPrompt {
216    pub(crate) fn new(
217        connection_string: String,
218        nickname: Option<String>,
219        window: &mut Window,
220        cx: &mut Context<Self>,
221    ) -> Self {
222        Self {
223            connection_string: connection_string.into(),
224            nickname: nickname.map(|nickname| nickname.into()),
225            editor: cx.new(|cx| Editor::single_line(window, cx)),
226            status_message: None,
227            cancellation: None,
228            prompt: None,
229        }
230    }
231
232    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
233        self.cancellation = Some(tx);
234    }
235
236    pub fn set_prompt(
237        &mut self,
238        prompt: String,
239        tx: oneshot::Sender<String>,
240        window: &mut Window,
241        cx: &mut Context<Self>,
242    ) {
243        let theme = ThemeSettings::get_global(cx);
244
245        let refinement = TextStyleRefinement {
246            font_family: Some(theme.buffer_font.family.clone()),
247            font_features: Some(FontFeatures::disable_ligatures()),
248            font_size: Some(theme.buffer_font_size(cx).into()),
249            color: Some(cx.theme().colors().editor_foreground),
250            background_color: Some(gpui::transparent_black()),
251            ..Default::default()
252        };
253
254        self.editor.update(cx, |editor, cx| {
255            if prompt.contains("yes/no") {
256                editor.set_masked(false, cx);
257            } else {
258                editor.set_masked(true, cx);
259            }
260            editor.set_text_style_refinement(refinement);
261            editor.set_cursor_shape(CursorShape::Block, cx);
262        });
263
264        let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
265        self.prompt = Some((markdown, tx));
266        self.status_message.take();
267        window.focus(&self.editor.focus_handle(cx));
268        cx.notify();
269    }
270
271    pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
272        self.status_message = status.map(|s| s.into());
273        cx.notify();
274    }
275
276    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
277        if let Some((_, tx)) = self.prompt.take() {
278            self.status_message = Some("Connecting".into());
279            self.editor.update(cx, |editor, cx| {
280                tx.send(editor.text(cx)).ok();
281                editor.clear(window, cx);
282            });
283        }
284    }
285}
286
287impl Render for RemoteConnectionPrompt {
288    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
289        let theme = ThemeSettings::get_global(cx);
290
291        let mut text_style = window.text_style();
292        let refinement = TextStyleRefinement {
293            font_family: Some(theme.buffer_font.family.clone()),
294            font_features: Some(FontFeatures::disable_ligatures()),
295            font_size: Some(theme.buffer_font_size(cx).into()),
296            color: Some(cx.theme().colors().editor_foreground),
297            background_color: Some(gpui::transparent_black()),
298            ..Default::default()
299        };
300
301        text_style.refine(&refinement);
302        let markdown_style = MarkdownStyle {
303            base_text_style: text_style,
304            selection_background_color: cx.theme().colors().element_selection_background,
305            ..Default::default()
306        };
307
308        v_flex()
309            .key_context("PasswordPrompt")
310            .py_2()
311            .px_3()
312            .size_full()
313            .text_buffer(cx)
314            .when_some(self.status_message.clone(), |el, status_message| {
315                el.child(
316                    h_flex()
317                        .gap_1()
318                        .child(
319                            Icon::new(IconName::ArrowCircle)
320                                .size(IconSize::Medium)
321                                .with_rotate_animation(2),
322                        )
323                        .child(
324                            div()
325                                .text_ellipsis()
326                                .overflow_x_hidden()
327                                .child(format!("{}", status_message)),
328                        ),
329                )
330            })
331            .when_some(self.prompt.as_ref(), |el, prompt| {
332                el.child(
333                    div()
334                        .size_full()
335                        .overflow_hidden()
336                        .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
337                        .child(self.editor.clone()),
338                )
339                .when(window.capslock().on, |el| {
340                    el.child(Label::new("⚠️ ⇪ is on"))
341                })
342            })
343    }
344}
345
346impl RemoteConnectionModal {
347    pub(crate) fn new(
348        connection_options: &RemoteConnectionOptions,
349        paths: Vec<PathBuf>,
350        window: &mut Window,
351        cx: &mut Context<Self>,
352    ) -> Self {
353        let (connection_string, nickname) = match connection_options {
354            RemoteConnectionOptions::Ssh(options) => {
355                (options.connection_string(), options.nickname.clone())
356            }
357            RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None),
358        };
359        Self {
360            prompt: cx
361                .new(|cx| RemoteConnectionPrompt::new(connection_string, nickname, window, cx)),
362            finished: false,
363            paths,
364        }
365    }
366
367    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
368        self.prompt
369            .update(cx, |prompt, cx| prompt.confirm(window, cx))
370    }
371
372    pub fn finished(&mut self, cx: &mut Context<Self>) {
373        self.finished = true;
374        cx.emit(DismissEvent);
375    }
376
377    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
378        if let Some(tx) = self
379            .prompt
380            .update(cx, |prompt, _cx| prompt.cancellation.take())
381        {
382            tx.send(()).ok();
383        }
384        self.finished(cx);
385    }
386}
387
388pub(crate) struct SshConnectionHeader {
389    pub(crate) connection_string: SharedString,
390    pub(crate) paths: Vec<PathBuf>,
391    pub(crate) nickname: Option<SharedString>,
392}
393
394impl RenderOnce for SshConnectionHeader {
395    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
396        let theme = cx.theme();
397
398        let mut header_color = theme.colors().text;
399        header_color.fade_out(0.96);
400
401        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
402            (nickname, Some(format!("({})", self.connection_string)))
403        } else {
404            (self.connection_string, None)
405        };
406
407        h_flex()
408            .px(DynamicSpacing::Base12.rems(cx))
409            .pt(DynamicSpacing::Base08.rems(cx))
410            .pb(DynamicSpacing::Base04.rems(cx))
411            .rounded_t_sm()
412            .w_full()
413            .gap_1p5()
414            .child(Icon::new(IconName::Server).size(IconSize::Small))
415            .child(
416                h_flex()
417                    .gap_1()
418                    .overflow_x_hidden()
419                    .child(
420                        div()
421                            .max_w_96()
422                            .overflow_x_hidden()
423                            .text_ellipsis()
424                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
425                    )
426                    .children(
427                        meta_label.map(|label| {
428                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
429                        }),
430                    )
431                    .child(div().overflow_x_hidden().text_ellipsis().children(
432                        self.paths.into_iter().map(|path| {
433                            Label::new(path.to_string_lossy().to_string())
434                                .size(LabelSize::Small)
435                                .color(Color::Muted)
436                        }),
437                    )),
438            )
439    }
440}
441
442impl Render for RemoteConnectionModal {
443    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
444        let nickname = self.prompt.read(cx).nickname.clone();
445        let connection_string = self.prompt.read(cx).connection_string.clone();
446
447        let theme = cx.theme().clone();
448        let body_color = theme.colors().editor_background;
449
450        v_flex()
451            .elevation_3(cx)
452            .w(rems(34.))
453            .border_1()
454            .border_color(theme.colors().border)
455            .key_context("SshConnectionModal")
456            .track_focus(&self.focus_handle(cx))
457            .on_action(cx.listener(Self::dismiss))
458            .on_action(cx.listener(Self::confirm))
459            .child(
460                SshConnectionHeader {
461                    paths: self.paths.clone(),
462                    connection_string,
463                    nickname,
464                }
465                .render(window, cx),
466            )
467            .child(
468                div()
469                    .w_full()
470                    .rounded_b_lg()
471                    .bg(body_color)
472                    .border_t_1()
473                    .border_color(theme.colors().border_variant)
474                    .child(self.prompt.clone()),
475            )
476    }
477}
478
479impl Focusable for RemoteConnectionModal {
480    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
481        self.prompt.read(cx).editor.focus_handle(cx)
482    }
483}
484
485impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
486
487impl ModalView for RemoteConnectionModal {
488    fn on_before_dismiss(
489        &mut self,
490        _window: &mut Window,
491        _: &mut Context<Self>,
492    ) -> workspace::DismissDecision {
493        workspace::DismissDecision::Dismiss(self.finished)
494    }
495
496    fn fade_out_background(&self) -> bool {
497        true
498    }
499}
500
501#[derive(Clone)]
502pub struct RemoteClientDelegate {
503    window: AnyWindowHandle,
504    ui: WeakEntity<RemoteConnectionPrompt>,
505    known_password: Option<String>,
506}
507
508impl remote::RemoteClientDelegate for RemoteClientDelegate {
509    fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
510        let mut known_password = self.known_password.clone();
511        if let Some(password) = known_password.take() {
512            tx.send(password).ok();
513        } else {
514            self.window
515                .update(cx, |_, window, cx| {
516                    self.ui.update(cx, |modal, cx| {
517                        modal.set_prompt(prompt, tx, window, cx);
518                    })
519                })
520                .ok();
521        }
522    }
523
524    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
525        self.update_status(status, cx)
526    }
527
528    fn download_server_binary_locally(
529        &self,
530        platform: RemotePlatform,
531        release_channel: ReleaseChannel,
532        version: Option<SemanticVersion>,
533        cx: &mut AsyncApp,
534    ) -> Task<anyhow::Result<PathBuf>> {
535        cx.spawn(async move |cx| {
536            let binary_path = AutoUpdater::download_remote_server_release(
537                platform.os,
538                platform.arch,
539                release_channel,
540                version,
541                cx,
542            )
543            .await
544            .with_context(|| {
545                format!(
546                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
547                    version
548                        .map(|v| format!("{}", v))
549                        .unwrap_or("unknown".to_string()),
550                    platform.os,
551                    platform.arch,
552                )
553            })?;
554            Ok(binary_path)
555        })
556    }
557
558    fn get_download_params(
559        &self,
560        platform: RemotePlatform,
561        release_channel: ReleaseChannel,
562        version: Option<SemanticVersion>,
563        cx: &mut AsyncApp,
564    ) -> Task<Result<Option<(String, String)>>> {
565        cx.spawn(async move |cx| {
566            AutoUpdater::get_remote_server_release_url(
567                platform.os,
568                platform.arch,
569                release_channel,
570                version,
571                cx,
572            )
573            .await
574        })
575    }
576}
577
578impl RemoteClientDelegate {
579    fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
580        self.window
581            .update(cx, |_, _, cx| {
582                self.ui.update(cx, |modal, cx| {
583                    modal.set_status(status.map(|s| s.to_string()), cx);
584                })
585            })
586            .ok();
587    }
588}
589
590pub fn connect_over_ssh(
591    unique_identifier: ConnectionIdentifier,
592    connection_options: SshConnectionOptions,
593    ui: Entity<RemoteConnectionPrompt>,
594    window: &mut Window,
595    cx: &mut App,
596) -> Task<Result<Option<Entity<RemoteClient>>>> {
597    let window = window.window_handle();
598    let known_password = connection_options.password.clone();
599    let (tx, rx) = oneshot::channel();
600    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
601
602    remote::RemoteClient::ssh(
603        unique_identifier,
604        connection_options,
605        rx,
606        Arc::new(RemoteClientDelegate {
607            window,
608            ui: ui.downgrade(),
609            known_password,
610        }),
611        cx,
612    )
613}
614
615pub fn connect(
616    unique_identifier: ConnectionIdentifier,
617    connection_options: RemoteConnectionOptions,
618    ui: Entity<RemoteConnectionPrompt>,
619    window: &mut Window,
620    cx: &mut App,
621) -> Task<Result<Option<Entity<RemoteClient>>>> {
622    let window = window.window_handle();
623    let known_password = match &connection_options {
624        RemoteConnectionOptions::Ssh(ssh_connection_options) => {
625            ssh_connection_options.password.clone()
626        }
627        _ => None,
628    };
629    let (tx, rx) = oneshot::channel();
630    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
631
632    remote::RemoteClient::new(
633        unique_identifier,
634        connection_options,
635        rx,
636        Arc::new(RemoteClientDelegate {
637            window,
638            ui: ui.downgrade(),
639            known_password,
640        }),
641        cx,
642    )
643}
644
645pub async fn open_remote_project(
646    connection_options: RemoteConnectionOptions,
647    paths: Vec<PathBuf>,
648    app_state: Arc<AppState>,
649    open_options: workspace::OpenOptions,
650    cx: &mut AsyncApp,
651) -> Result<()> {
652    let window = if let Some(window) = open_options.replace_window {
653        window
654    } else {
655        let workspace_position = cx
656            .update(|cx| {
657                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
658            })?
659            .await
660            .context("fetching ssh workspace position from db")?;
661
662        let mut options =
663            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
664        options.window_bounds = workspace_position.window_bounds;
665
666        cx.open_window(options, |window, cx| {
667            let project = project::Project::local(
668                app_state.client.clone(),
669                app_state.node_runtime.clone(),
670                app_state.user_store.clone(),
671                app_state.languages.clone(),
672                app_state.fs.clone(),
673                None,
674                cx,
675            );
676            cx.new(|cx| {
677                let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
678                workspace.centered_layout = workspace_position.centered_layout;
679                workspace
680            })
681        })?
682    };
683
684    loop {
685        let (cancel_tx, cancel_rx) = oneshot::channel();
686        let delegate = window.update(cx, {
687            let paths = paths.clone();
688            let connection_options = connection_options.clone();
689            move |workspace, window, cx| {
690                window.activate_window();
691                workspace.toggle_modal(window, cx, |window, cx| {
692                    RemoteConnectionModal::new(&connection_options, paths, window, cx)
693                });
694
695                let ui = workspace
696                    .active_modal::<RemoteConnectionModal>(cx)?
697                    .read(cx)
698                    .prompt
699                    .clone();
700
701                ui.update(cx, |ui, _cx| {
702                    ui.set_cancellation_tx(cancel_tx);
703                });
704
705                Some(Arc::new(RemoteClientDelegate {
706                    window: window.window_handle(),
707                    ui: ui.downgrade(),
708                    known_password: if let RemoteConnectionOptions::Ssh(options) =
709                        &connection_options
710                    {
711                        options.password.clone()
712                    } else {
713                        None
714                    },
715                }))
716            }
717        })?;
718
719        let Some(delegate) = delegate else { break };
720
721        let did_open_project = cx
722            .update(|cx| {
723                workspace::open_remote_project_with_new_connection(
724                    window,
725                    connection_options.clone(),
726                    cancel_rx,
727                    delegate.clone(),
728                    app_state.clone(),
729                    paths.clone(),
730                    cx,
731                )
732            })?
733            .await;
734
735        window
736            .update(cx, |workspace, _, cx| {
737                if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
738                    ui.update(cx, |modal, cx| modal.finished(cx))
739                }
740            })
741            .ok();
742
743        if let Err(e) = did_open_project {
744            log::error!("Failed to open project: {e:?}");
745            let response = window
746                .update(cx, |_, window, cx| {
747                    window.prompt(
748                        PromptLevel::Critical,
749                        match connection_options {
750                            RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
751                            RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
752                        },
753                        Some(&e.to_string()),
754                        &["Retry", "Ok"],
755                        cx,
756                    )
757                })?
758                .await;
759
760            if response == Ok(0) {
761                continue;
762            }
763        }
764
765        window
766            .update(cx, |workspace, _, cx| {
767                if let Some(client) = workspace.project().read(cx).remote_client() {
768                    ExtensionStore::global(cx)
769                        .update(cx, |store, cx| store.register_remote_client(client, cx));
770                }
771            })
772            .ok();
773
774        break;
775    }
776
777    // Already showed the error to the user
778    Ok(())
779}