ssh_connections.rs

  1use std::{path::PathBuf, sync::Arc, time::Duration};
  2
  3use anyhow::{anyhow, Result};
  4use auto_update::AutoUpdater;
  5use editor::Editor;
  6use futures::channel::oneshot;
  7use gpui::{
  8    percentage, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
  9    EventEmitter, FocusableView, ParentElement as _, PromptLevel, Render, SemanticVersion,
 10    SharedString, Task, TextStyleRefinement, Transformation, View, WeakView,
 11};
 12use gpui::{AppContext, Model};
 13
 14use language::CursorShape;
 15use markdown::{Markdown, MarkdownStyle};
 16use release_channel::{AppVersion, ReleaseChannel};
 17use remote::ssh_session::{ServerBinary, ServerVersion};
 18use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
 19use schemars::JsonSchema;
 20use serde::{Deserialize, Serialize};
 21use settings::{Settings, SettingsSources};
 22use theme::ThemeSettings;
 23use ui::{
 24    prelude::*, ActiveTheme, Color, Icon, IconName, IconSize, InteractiveElement, IntoElement,
 25    Label, LabelCommon, Styled, ViewContext, VisualContext, WindowContext,
 26};
 27use workspace::{AppState, ModalView, Workspace};
 28
 29#[derive(Deserialize)]
 30pub struct SshSettings {
 31    pub ssh_connections: Option<Vec<SshConnection>>,
 32}
 33
 34impl SshSettings {
 35    pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
 36        self.ssh_connections.clone().into_iter().flatten()
 37    }
 38
 39    pub fn connection_options_for(
 40        &self,
 41        host: String,
 42        port: Option<u16>,
 43        username: Option<String>,
 44    ) -> SshConnectionOptions {
 45        for conn in self.ssh_connections() {
 46            if conn.host == host && conn.username == username && conn.port == port {
 47                return SshConnectionOptions {
 48                    nickname: conn.nickname,
 49                    upload_binary_over_ssh: conn.upload_binary_over_ssh.unwrap_or_default(),
 50                    args: Some(conn.args),
 51                    host,
 52                    port,
 53                    username,
 54                    password: None,
 55                };
 56            }
 57        }
 58        SshConnectionOptions {
 59            host,
 60            port,
 61            username,
 62            ..Default::default()
 63        }
 64    }
 65}
 66
 67#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 68pub struct SshConnection {
 69    pub host: SharedString,
 70    #[serde(skip_serializing_if = "Option::is_none")]
 71    pub username: Option<String>,
 72    #[serde(skip_serializing_if = "Option::is_none")]
 73    pub port: Option<u16>,
 74    #[serde(skip_serializing_if = "Vec::is_empty")]
 75    #[serde(default)]
 76    pub args: Vec<String>,
 77    #[serde(default)]
 78    pub projects: Vec<SshProject>,
 79    /// Name to use for this server in UI.
 80    #[serde(skip_serializing_if = "Option::is_none")]
 81    pub nickname: Option<String>,
 82    // By default Zed will download the binary to the host directly.
 83    // If this is set to true, Zed will download the binary to your local machine,
 84    // and then upload it over the SSH connection. Useful if your SSH server has
 85    // limited outbound internet access.
 86    #[serde(skip_serializing_if = "Option::is_none")]
 87    pub upload_binary_over_ssh: Option<bool>,
 88}
 89
 90impl From<SshConnection> for SshConnectionOptions {
 91    fn from(val: SshConnection) -> Self {
 92        SshConnectionOptions {
 93            host: val.host.into(),
 94            username: val.username,
 95            port: val.port,
 96            password: None,
 97            args: Some(val.args),
 98            nickname: val.nickname,
 99            upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
100        }
101    }
102}
103
104#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
105pub struct SshProject {
106    pub paths: Vec<String>,
107}
108
109#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
110pub struct RemoteSettingsContent {
111    pub ssh_connections: Option<Vec<SshConnection>>,
112}
113
114impl Settings for SshSettings {
115    const KEY: Option<&'static str> = None;
116
117    type FileContent = RemoteSettingsContent;
118
119    fn load(sources: SettingsSources<Self::FileContent>, _: &mut AppContext) -> Result<Self> {
120        sources.json_merge()
121    }
122}
123
124pub struct SshPrompt {
125    connection_string: SharedString,
126    nickname: Option<SharedString>,
127    status_message: Option<SharedString>,
128    prompt: Option<(View<Markdown>, oneshot::Sender<Result<String>>)>,
129    cancellation: Option<oneshot::Sender<()>>,
130    editor: View<Editor>,
131}
132
133impl Drop for SshPrompt {
134    fn drop(&mut self) {
135        if let Some(cancel) = self.cancellation.take() {
136            cancel.send(()).ok();
137        }
138    }
139}
140
141pub struct SshConnectionModal {
142    pub(crate) prompt: View<SshPrompt>,
143    paths: Vec<PathBuf>,
144    finished: bool,
145}
146
147impl SshPrompt {
148    pub(crate) fn new(
149        connection_options: &SshConnectionOptions,
150        cx: &mut ViewContext<Self>,
151    ) -> Self {
152        let connection_string = connection_options.connection_string().into();
153        let nickname = connection_options.nickname.clone().map(|s| s.into());
154
155        Self {
156            connection_string,
157            nickname,
158            editor: cx.new_view(Editor::single_line),
159            status_message: None,
160            cancellation: None,
161            prompt: None,
162        }
163    }
164
165    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
166        self.cancellation = Some(tx);
167    }
168
169    pub fn set_prompt(
170        &mut self,
171        prompt: String,
172        tx: oneshot::Sender<Result<String>>,
173        cx: &mut ViewContext<Self>,
174    ) {
175        let theme = ThemeSettings::get_global(cx);
176
177        let mut text_style = cx.text_style();
178        let refinement = TextStyleRefinement {
179            font_family: Some(theme.buffer_font.family.clone()),
180            font_size: Some(theme.buffer_font_size.into()),
181            color: Some(cx.theme().colors().editor_foreground),
182            background_color: Some(gpui::transparent_black()),
183            ..Default::default()
184        };
185
186        text_style.refine(&refinement);
187        self.editor.update(cx, |editor, cx| {
188            if prompt.contains("yes/no") {
189                editor.set_masked(false, cx);
190            } else {
191                editor.set_masked(true, cx);
192            }
193            editor.set_text_style_refinement(refinement);
194            editor.set_cursor_shape(CursorShape::Block, cx);
195        });
196        let markdown_style = MarkdownStyle {
197            base_text_style: text_style,
198            selection_background_color: cx.theme().players().local().selection,
199            ..Default::default()
200        };
201        let markdown = cx.new_view(|cx| Markdown::new_text(prompt, markdown_style, None, cx, None));
202        self.prompt = Some((markdown, tx));
203        self.status_message.take();
204        cx.focus_view(&self.editor);
205        cx.notify();
206    }
207
208    pub fn set_status(&mut self, status: Option<String>, cx: &mut ViewContext<Self>) {
209        self.status_message = status.map(|s| s.into());
210        cx.notify();
211    }
212
213    pub fn confirm(&mut self, cx: &mut ViewContext<Self>) {
214        if let Some((_, tx)) = self.prompt.take() {
215            self.status_message = Some("Connecting".into());
216            self.editor.update(cx, |editor, cx| {
217                tx.send(Ok(editor.text(cx))).ok();
218                editor.clear(cx);
219            });
220        }
221    }
222}
223
224impl Render for SshPrompt {
225    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
226        let cx = cx.window_context();
227
228        v_flex()
229            .key_context("PasswordPrompt")
230            .py_2()
231            .px_3()
232            .size_full()
233            .text_buffer(cx)
234            .when_some(self.status_message.clone(), |el, status_message| {
235                el.child(
236                    h_flex()
237                        .gap_1()
238                        .child(
239                            Icon::new(IconName::ArrowCircle)
240                                .size(IconSize::Medium)
241                                .with_animation(
242                                    "arrow-circle",
243                                    Animation::new(Duration::from_secs(2)).repeat(),
244                                    |icon, delta| {
245                                        icon.transform(Transformation::rotate(percentage(delta)))
246                                    },
247                                ),
248                        )
249                        .child(
250                            div()
251                                .text_ellipsis()
252                                .overflow_x_hidden()
253                                .child(format!("{}", status_message)),
254                        ),
255                )
256            })
257            .when_some(self.prompt.as_ref(), |el, prompt| {
258                el.child(
259                    div()
260                        .size_full()
261                        .overflow_hidden()
262                        .child(prompt.0.clone())
263                        .child(self.editor.clone()),
264                )
265            })
266    }
267}
268
269impl SshConnectionModal {
270    pub(crate) fn new(
271        connection_options: &SshConnectionOptions,
272        paths: Vec<PathBuf>,
273        cx: &mut ViewContext<Self>,
274    ) -> Self {
275        Self {
276            prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
277            finished: false,
278            paths,
279        }
280    }
281
282    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
283        self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
284    }
285
286    pub fn finished(&mut self, cx: &mut ViewContext<Self>) {
287        self.finished = true;
288        cx.emit(DismissEvent);
289    }
290
291    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
292        if let Some(tx) = self
293            .prompt
294            .update(cx, |prompt, _cx| prompt.cancellation.take())
295        {
296            tx.send(()).ok();
297        }
298        self.finished(cx);
299    }
300}
301
302pub(crate) struct SshConnectionHeader {
303    pub(crate) connection_string: SharedString,
304    pub(crate) paths: Vec<PathBuf>,
305    pub(crate) nickname: Option<SharedString>,
306}
307
308impl RenderOnce for SshConnectionHeader {
309    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
310        let theme = cx.theme();
311
312        let mut header_color = theme.colors().text;
313        header_color.fade_out(0.96);
314
315        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
316            (nickname, Some(format!("({})", self.connection_string)))
317        } else {
318            (self.connection_string, None)
319        };
320
321        h_flex()
322            .px(Spacing::XLarge.rems(cx))
323            .pt(Spacing::Large.rems(cx))
324            .pb(Spacing::Small.rems(cx))
325            .rounded_t_md()
326            .w_full()
327            .gap_1p5()
328            .child(Icon::new(IconName::Server).size(IconSize::XSmall))
329            .child(
330                h_flex()
331                    .gap_1()
332                    .overflow_x_hidden()
333                    .child(
334                        div()
335                            .max_w_96()
336                            .overflow_x_hidden()
337                            .text_ellipsis()
338                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
339                    )
340                    .children(
341                        meta_label.map(|label| {
342                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
343                        }),
344                    )
345                    .child(div().overflow_x_hidden().text_ellipsis().children(
346                        self.paths.into_iter().map(|path| {
347                            Label::new(path.to_string_lossy().to_string())
348                                .size(LabelSize::Small)
349                                .color(Color::Muted)
350                        }),
351                    )),
352            )
353    }
354}
355
356impl Render for SshConnectionModal {
357    fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
358        let nickname = self.prompt.read(cx).nickname.clone();
359        let connection_string = self.prompt.read(cx).connection_string.clone();
360
361        let theme = cx.theme().clone();
362        let body_color = theme.colors().editor_background;
363
364        v_flex()
365            .elevation_3(cx)
366            .w(rems(34.))
367            .border_1()
368            .border_color(theme.colors().border)
369            .key_context("SshConnectionModal")
370            .track_focus(&self.focus_handle(cx))
371            .on_action(cx.listener(Self::dismiss))
372            .on_action(cx.listener(Self::confirm))
373            .child(
374                SshConnectionHeader {
375                    paths: self.paths.clone(),
376                    connection_string,
377                    nickname,
378                }
379                .render(cx),
380            )
381            .child(
382                div()
383                    .w_full()
384                    .rounded_b_lg()
385                    .bg(body_color)
386                    .border_t_1()
387                    .border_color(theme.colors().border_variant)
388                    .child(self.prompt.clone()),
389            )
390    }
391}
392
393impl FocusableView for SshConnectionModal {
394    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
395        self.prompt.read(cx).editor.focus_handle(cx)
396    }
397}
398
399impl EventEmitter<DismissEvent> for SshConnectionModal {}
400
401impl ModalView for SshConnectionModal {
402    fn on_before_dismiss(&mut self, _: &mut ViewContext<Self>) -> workspace::DismissDecision {
403        return workspace::DismissDecision::Dismiss(self.finished);
404    }
405
406    fn fade_out_background(&self) -> bool {
407        true
408    }
409}
410
411#[derive(Clone)]
412pub struct SshClientDelegate {
413    window: AnyWindowHandle,
414    ui: WeakView<SshPrompt>,
415    known_password: Option<String>,
416}
417
418impl remote::SshClientDelegate for SshClientDelegate {
419    fn ask_password(
420        &self,
421        prompt: String,
422        cx: &mut AsyncAppContext,
423    ) -> oneshot::Receiver<Result<String>> {
424        let (tx, rx) = oneshot::channel();
425        let mut known_password = self.known_password.clone();
426        if let Some(password) = known_password.take() {
427            tx.send(Ok(password)).ok();
428        } else {
429            self.window
430                .update(cx, |_, cx| {
431                    self.ui.update(cx, |modal, cx| {
432                        modal.set_prompt(prompt, tx, cx);
433                    })
434                })
435                .ok();
436        }
437        rx
438    }
439
440    fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
441        self.update_status(status, cx)
442    }
443
444    fn get_server_binary(
445        &self,
446        platform: SshPlatform,
447        upload_binary_over_ssh: bool,
448        cx: &mut AsyncAppContext,
449    ) -> oneshot::Receiver<Result<(ServerBinary, ServerVersion)>> {
450        let (tx, rx) = oneshot::channel();
451        let this = self.clone();
452        cx.spawn(|mut cx| async move {
453            tx.send(
454                this.get_server_binary_impl(platform, upload_binary_over_ssh, &mut cx)
455                    .await,
456            )
457            .ok();
458        })
459        .detach();
460        rx
461    }
462
463    fn remote_server_binary_path(
464        &self,
465        platform: SshPlatform,
466        cx: &mut AsyncAppContext,
467    ) -> Result<PathBuf> {
468        let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
469        Ok(paths::remote_server_dir_relative().join(format!(
470            "zed-remote-server-{}-{}-{}",
471            release_channel.dev_name(),
472            platform.os,
473            platform.arch
474        )))
475    }
476}
477
478impl SshClientDelegate {
479    fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
480        self.window
481            .update(cx, |_, cx| {
482                self.ui.update(cx, |modal, cx| {
483                    modal.set_status(status.map(|s| s.to_string()), cx);
484                })
485            })
486            .ok();
487    }
488
489    async fn get_server_binary_impl(
490        &self,
491        platform: SshPlatform,
492        upload_binary_via_ssh: bool,
493        cx: &mut AsyncAppContext,
494    ) -> Result<(ServerBinary, ServerVersion)> {
495        let (version, release_channel) = cx.update(|cx| {
496            let version = AppVersion::global(cx);
497            let channel = ReleaseChannel::global(cx);
498
499            (version, channel)
500        })?;
501
502        // In dev mode, build the remote server binary from source
503        #[cfg(debug_assertions)]
504        if release_channel == ReleaseChannel::Dev {
505            let result = self.build_local(cx, platform, version).await?;
506            // Fall through to a remote binary if we're not able to compile a local binary
507            if let Some((path, version)) = result {
508                return Ok((
509                    ServerBinary::LocalBinary(path),
510                    ServerVersion::Semantic(version),
511                ));
512            }
513        }
514
515        // For nightly channel, always get latest
516        let current_version = if release_channel == ReleaseChannel::Nightly {
517            None
518        } else {
519            Some(version)
520        };
521
522        self.update_status(
523            Some(&format!("Checking remote server release {}", version)),
524            cx,
525        );
526
527        if upload_binary_via_ssh {
528            let binary_path = AutoUpdater::download_remote_server_release(
529                platform.os,
530                platform.arch,
531                release_channel,
532                current_version,
533                cx,
534            )
535            .await
536            .map_err(|e| {
537                anyhow!(
538                    "Failed to download remote server binary (version: {}, os: {}, arch: {}): {}",
539                    version,
540                    platform.os,
541                    platform.arch,
542                    e
543                )
544            })?;
545
546            Ok((
547                ServerBinary::LocalBinary(binary_path),
548                ServerVersion::Semantic(version),
549            ))
550        } else {
551            let (release, request_body) = AutoUpdater::get_remote_server_release_url(
552                    platform.os,
553                    platform.arch,
554                    release_channel,
555                    current_version,
556                    cx,
557                )
558                .await
559                .map_err(|e| {
560                    anyhow!(
561                        "Failed to get remote server binary download url (version: {}, os: {}, arch: {}): {}",
562                        version,
563                        platform.os,
564                        platform.arch,
565                        e
566                    )
567                })?;
568
569            let version = release
570                .version
571                .parse::<SemanticVersion>()
572                .map(ServerVersion::Semantic)
573                .unwrap_or_else(|_| ServerVersion::Commit(release.version));
574            Ok((
575                ServerBinary::ReleaseUrl {
576                    url: release.url,
577                    body: request_body,
578                },
579                version,
580            ))
581        }
582    }
583
584    #[cfg(debug_assertions)]
585    async fn build_local(
586        &self,
587        cx: &mut AsyncAppContext,
588        platform: SshPlatform,
589        version: gpui::SemanticVersion,
590    ) -> Result<Option<(PathBuf, gpui::SemanticVersion)>> {
591        use smol::process::{Command, Stdio};
592
593        async fn run_cmd(command: &mut Command) -> Result<()> {
594            let output = command
595                .kill_on_drop(true)
596                .stderr(Stdio::inherit())
597                .output()
598                .await?;
599            if !output.status.success() {
600                Err(anyhow!("Failed to run command: {:?}", command))?;
601            }
602            Ok(())
603        }
604
605        if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS {
606            self.update_status(Some("Building remote server binary from source"), cx);
607            log::info!("building remote server binary from source");
608            run_cmd(Command::new("cargo").args([
609                "build",
610                "--package",
611                "remote_server",
612                "--features",
613                "debug-embed",
614                "--target-dir",
615                "target/remote_server",
616            ]))
617            .await?;
618
619            self.update_status(Some("Compressing binary"), cx);
620
621            run_cmd(Command::new("gzip").args([
622                "-9",
623                "-f",
624                "target/remote_server/debug/remote_server",
625            ]))
626            .await?;
627
628            let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
629            return Ok(Some((path, version)));
630        } else if let Some(triple) = platform.triple() {
631            smol::fs::create_dir_all("target/remote_server").await?;
632
633            self.update_status(Some("Installing cross.rs for cross-compilation"), cx);
634            log::info!("installing cross");
635            run_cmd(Command::new("cargo").args([
636                "install",
637                "cross",
638                "--git",
639                "https://github.com/cross-rs/cross",
640            ]))
641            .await?;
642
643            self.update_status(
644                Some(&format!(
645                    "Building remote server binary from source for {} with Docker",
646                    &triple
647                )),
648                cx,
649            );
650            log::info!("building remote server binary from source for {}", &triple);
651            run_cmd(
652                Command::new("cross")
653                    .args([
654                        "build",
655                        "--package",
656                        "remote_server",
657                        "--features",
658                        "debug-embed",
659                        "--target-dir",
660                        "target/remote_server",
661                        "--target",
662                        &triple,
663                    ])
664                    .env(
665                        "CROSS_CONTAINER_OPTS",
666                        "--mount type=bind,src=./target,dst=/app/target",
667                    ),
668            )
669            .await?;
670
671            self.update_status(Some("Compressing binary"), cx);
672
673            run_cmd(Command::new("gzip").args([
674                "-9",
675                "-f",
676                &format!("target/remote_server/{}/debug/remote_server", triple),
677            ]))
678            .await?;
679
680            let path = std::env::current_dir()?.join(format!(
681                "target/remote_server/{}/debug/remote_server.gz",
682                triple
683            ));
684
685            return Ok(Some((path, version)));
686        } else {
687            return Ok(None);
688        }
689    }
690}
691
692pub fn is_connecting_over_ssh(workspace: &Workspace, cx: &AppContext) -> bool {
693    workspace.active_modal::<SshConnectionModal>(cx).is_some()
694}
695
696pub fn connect_over_ssh(
697    unique_identifier: String,
698    connection_options: SshConnectionOptions,
699    ui: View<SshPrompt>,
700    cx: &mut WindowContext,
701) -> Task<Result<Option<Model<SshRemoteClient>>>> {
702    let window = cx.window_handle();
703    let known_password = connection_options.password.clone();
704    let (tx, rx) = oneshot::channel();
705    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
706
707    remote::SshRemoteClient::new(
708        unique_identifier,
709        connection_options,
710        rx,
711        Arc::new(SshClientDelegate {
712            window,
713            ui: ui.downgrade(),
714            known_password,
715        }),
716        cx,
717    )
718}
719
720pub async fn open_ssh_project(
721    connection_options: SshConnectionOptions,
722    paths: Vec<PathBuf>,
723    app_state: Arc<AppState>,
724    open_options: workspace::OpenOptions,
725    cx: &mut AsyncAppContext,
726) -> Result<()> {
727    let window = if let Some(window) = open_options.replace_window {
728        window
729    } else {
730        let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
731        cx.open_window(options, |cx| {
732            let project = project::Project::local(
733                app_state.client.clone(),
734                app_state.node_runtime.clone(),
735                app_state.user_store.clone(),
736                app_state.languages.clone(),
737                app_state.fs.clone(),
738                None,
739                cx,
740            );
741            cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
742        })?
743    };
744
745    loop {
746        let (cancel_tx, cancel_rx) = oneshot::channel();
747        let delegate = window.update(cx, {
748            let connection_options = connection_options.clone();
749            let paths = paths.clone();
750            move |workspace, cx| {
751                cx.activate_window();
752                workspace.toggle_modal(cx, |cx| {
753                    SshConnectionModal::new(&connection_options, paths, cx)
754                });
755
756                let ui = workspace
757                    .active_modal::<SshConnectionModal>(cx)?
758                    .read(cx)
759                    .prompt
760                    .clone();
761
762                ui.update(cx, |ui, _cx| {
763                    ui.set_cancellation_tx(cancel_tx);
764                });
765
766                Some(Arc::new(SshClientDelegate {
767                    window: cx.window_handle(),
768                    ui: ui.downgrade(),
769                    known_password: connection_options.password.clone(),
770                }))
771            }
772        })?;
773
774        let Some(delegate) = delegate else { break };
775
776        let did_open_ssh_project = cx
777            .update(|cx| {
778                workspace::open_ssh_project(
779                    window,
780                    connection_options.clone(),
781                    cancel_rx,
782                    delegate.clone(),
783                    app_state.clone(),
784                    paths.clone(),
785                    cx,
786                )
787            })?
788            .await;
789
790        window
791            .update(cx, |workspace, cx| {
792                if let Some(ui) = workspace.active_modal::<SshConnectionModal>(cx) {
793                    ui.update(cx, |modal, cx| modal.finished(cx))
794                }
795            })
796            .ok();
797
798        if let Err(e) = did_open_ssh_project {
799            log::error!("Failed to open project: {:?}", e);
800            let response = window
801                .update(cx, |_, cx| {
802                    cx.prompt(
803                        PromptLevel::Critical,
804                        "Failed to connect over SSH",
805                        Some(&e.to_string()),
806                        &["Retry", "Ok"],
807                    )
808                })?
809                .await;
810
811            if response == Ok(0) {
812                continue;
813            }
814        }
815
816        break;
817    }
818
819    // Already showed the error to the user
820    Ok(())
821}