remote_connection.rs

  1use std::{path::PathBuf, sync::Arc};
  2
  3use anyhow::Result;
  4use askpass::EncryptedPassword;
  5use auto_update::AutoUpdater;
  6use futures::{FutureExt as _, channel::oneshot, select};
  7use gpui::{
  8    AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
  9    ParentElement as _, Render, SharedString, Task, TextStyleRefinement, WeakEntity,
 10};
 11use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 12use release_channel::ReleaseChannel;
 13use remote::{ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform};
 14use semver::Version;
 15use settings::Settings;
 16use theme_settings::ThemeSettings;
 17use ui::{
 18    ActiveTheme, CommonAnimationExt, Context, InteractiveElement, KeyBinding, ListItem, Tooltip,
 19    prelude::*,
 20};
 21use ui_input::{ERASED_EDITOR_FACTORY, ErasedEditor};
 22use workspace::{DismissDecision, ModalView, Workspace};
 23
 24pub struct RemoteConnectionPrompt {
 25    connection_string: SharedString,
 26    nickname: Option<SharedString>,
 27    is_wsl: bool,
 28    is_devcontainer: bool,
 29    status_message: Option<SharedString>,
 30    prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
 31    cancellation: Option<oneshot::Sender<()>>,
 32    editor: Arc<dyn ErasedEditor>,
 33    is_password_prompt: bool,
 34    is_masked: bool,
 35}
 36
 37impl Drop for RemoteConnectionPrompt {
 38    fn drop(&mut self) {
 39        if let Some(cancel) = self.cancellation.take() {
 40            log::debug!("cancelling remote connection");
 41            cancel.send(()).ok();
 42        }
 43    }
 44}
 45
 46pub struct RemoteConnectionModal {
 47    pub prompt: Entity<RemoteConnectionPrompt>,
 48    paths: Vec<PathBuf>,
 49    finished: bool,
 50}
 51
 52impl RemoteConnectionPrompt {
 53    pub fn new(
 54        connection_string: String,
 55        nickname: Option<String>,
 56        is_wsl: bool,
 57        is_devcontainer: bool,
 58        window: &mut Window,
 59        cx: &mut Context<Self>,
 60    ) -> Self {
 61        let editor_factory = ERASED_EDITOR_FACTORY
 62            .get()
 63            .expect("ErasedEditorFactory to be initialized");
 64        let editor = (editor_factory)(window, cx);
 65
 66        Self {
 67            connection_string: connection_string.into(),
 68            nickname: nickname.map(|nickname| nickname.into()),
 69            is_wsl,
 70            is_devcontainer,
 71            editor,
 72            status_message: None,
 73            cancellation: None,
 74            prompt: None,
 75            is_password_prompt: false,
 76            is_masked: true,
 77        }
 78    }
 79
 80    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
 81        self.cancellation = Some(tx);
 82    }
 83
 84    pub fn set_prompt(
 85        &mut self,
 86        prompt: String,
 87        tx: oneshot::Sender<EncryptedPassword>,
 88        window: &mut Window,
 89        cx: &mut Context<Self>,
 90    ) {
 91        let is_yes_no = prompt.contains("yes/no");
 92        self.is_password_prompt = !is_yes_no;
 93        self.is_masked = !is_yes_no;
 94        self.editor.set_masked(self.is_masked, window, cx);
 95
 96        let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
 97        self.prompt = Some((markdown, tx));
 98        self.status_message.take();
 99        window.focus(&self.editor.focus_handle(cx), cx);
100        cx.notify();
101    }
102
103    pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
104        self.status_message = status.map(|s| s.into());
105        cx.notify();
106    }
107
108    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
109        if let Some((_, tx)) = self.prompt.take() {
110            self.status_message = Some("Connecting".into());
111
112            let pw = self.editor.text(cx);
113            if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
114                tx.send(secure).ok();
115            }
116            self.editor.clear(window, cx);
117        }
118    }
119}
120
121impl Render for RemoteConnectionPrompt {
122    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
123        let theme = ThemeSettings::get_global(cx);
124
125        let mut text_style = window.text_style();
126        let refinement = TextStyleRefinement {
127            font_family: Some(theme.buffer_font.family.clone()),
128            font_features: Some(FontFeatures::disable_ligatures()),
129            font_size: Some(theme.buffer_font_size(cx).into()),
130            color: Some(cx.theme().colors().editor_foreground),
131            background_color: Some(gpui::transparent_black()),
132            ..Default::default()
133        };
134
135        text_style.refine(&refinement);
136        let markdown_style = MarkdownStyle {
137            base_text_style: text_style,
138            selection_background_color: cx.theme().colors().element_selection_background,
139            ..Default::default()
140        };
141
142        let is_password_prompt = self.is_password_prompt;
143        let is_masked = self.is_masked;
144        let (masked_password_icon, masked_password_tooltip) = if is_masked {
145            (IconName::Eye, "Toggle to Unmask Password")
146        } else {
147            (IconName::EyeOff, "Toggle to Mask Password")
148        };
149
150        v_flex()
151            .key_context("PasswordPrompt")
152            .p_2()
153            .size_full()
154            .when_some(self.prompt.as_ref(), |this, prompt| {
155                this.child(
156                    v_flex()
157                        .text_sm()
158                        .size_full()
159                        .overflow_hidden()
160                        .child(
161                            h_flex()
162                                .w_full()
163                                .justify_between()
164                                .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
165                                .when(is_password_prompt, |this| {
166                                    this.child(
167                                        IconButton::new("toggle_mask", masked_password_icon)
168                                            .icon_size(IconSize::Small)
169                                            .tooltip(Tooltip::text(masked_password_tooltip))
170                                            .on_click(cx.listener(|this, _, window, cx| {
171                                                this.is_masked = !this.is_masked;
172                                                this.editor.set_masked(this.is_masked, window, cx);
173                                                window.focus(&this.editor.focus_handle(cx), cx);
174                                                cx.notify();
175                                            })),
176                                    )
177                                }),
178                        )
179                        .child(div().flex_1().child(self.editor.render(window, cx))),
180                )
181                .when(window.capslock().on, |this| {
182                    this.child(
183                        h_flex()
184                            .py_0p5()
185                            .min_w_0()
186                            .w_full()
187                            .gap_1()
188                            .child(
189                                Icon::new(IconName::Warning)
190                                    .size(IconSize::Small)
191                                    .color(Color::Muted),
192                            )
193                            .child(
194                                Label::new("Caps lock is on.")
195                                    .size(LabelSize::Small)
196                                    .color(Color::Muted),
197                            ),
198                    )
199                })
200            })
201            .when_some(self.status_message.clone(), |this, status_message| {
202                this.child(
203                    h_flex()
204                        .min_w_0()
205                        .w_full()
206                        .mt_1()
207                        .gap_1()
208                        .child(
209                            Icon::new(IconName::LoadCircle)
210                                .size(IconSize::Small)
211                                .color(Color::Muted)
212                                .with_rotate_animation(2),
213                        )
214                        .child(
215                            Label::new(format!("{}", status_message))
216                                .size(LabelSize::Small)
217                                .color(Color::Muted)
218                                .truncate()
219                                .flex_1(),
220                        ),
221                )
222            })
223    }
224}
225
226impl RemoteConnectionModal {
227    pub fn new(
228        connection_options: &RemoteConnectionOptions,
229        paths: Vec<PathBuf>,
230        window: &mut Window,
231        cx: &mut Context<Self>,
232    ) -> Self {
233        let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
234            RemoteConnectionOptions::Ssh(options) => (
235                options.connection_string(),
236                options.nickname.clone(),
237                false,
238                false,
239            ),
240            RemoteConnectionOptions::Wsl(options) => {
241                (options.distro_name.clone(), None, true, false)
242            }
243            RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
244            #[cfg(any(test, feature = "test-support"))]
245            RemoteConnectionOptions::Mock(options) => {
246                (format!("mock-{}", options.id), None, false, false)
247            }
248        };
249        Self {
250            prompt: cx.new(|cx| {
251                RemoteConnectionPrompt::new(
252                    connection_string,
253                    nickname,
254                    is_wsl,
255                    is_devcontainer,
256                    window,
257                    cx,
258                )
259            }),
260            finished: false,
261            paths,
262        }
263    }
264
265    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
266        self.prompt
267            .update(cx, |prompt, cx| prompt.confirm(window, cx))
268    }
269
270    pub fn finished(&mut self, cx: &mut Context<Self>) {
271        self.finished = true;
272        cx.emit(DismissEvent);
273    }
274
275    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
276        if let Some(tx) = self
277            .prompt
278            .update(cx, |prompt, _cx| prompt.cancellation.take())
279        {
280            log::debug!("cancelling remote connection");
281            tx.send(()).ok();
282        }
283        self.finished(cx);
284    }
285}
286
287pub struct SshConnectionHeader {
288    pub connection_string: SharedString,
289    pub paths: Vec<PathBuf>,
290    pub nickname: Option<SharedString>,
291    pub is_wsl: bool,
292    pub is_devcontainer: bool,
293}
294
295impl RenderOnce for SshConnectionHeader {
296    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
297        let theme = cx.theme();
298
299        let mut header_color = theme.colors().text;
300        header_color.fade_out(0.96);
301
302        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
303            (nickname, Some(format!("({})", self.connection_string)))
304        } else {
305            (self.connection_string, None)
306        };
307
308        let icon = if self.is_wsl {
309            IconName::Linux
310        } else if self.is_devcontainer {
311            IconName::Box
312        } else {
313            IconName::Server
314        };
315
316        h_flex()
317            .px(DynamicSpacing::Base12.rems(cx))
318            .pt(DynamicSpacing::Base08.rems(cx))
319            .pb(DynamicSpacing::Base04.rems(cx))
320            .rounded_t_sm()
321            .w_full()
322            .gap_1p5()
323            .child(Icon::new(icon).size(IconSize::Small))
324            .child(
325                h_flex()
326                    .gap_1()
327                    .overflow_x_hidden()
328                    .child(
329                        div()
330                            .max_w_96()
331                            .overflow_x_hidden()
332                            .text_ellipsis()
333                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
334                    )
335                    .children(
336                        meta_label.map(|label| {
337                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
338                        }),
339                    )
340                    .child(div().overflow_x_hidden().text_ellipsis().children(
341                        self.paths.into_iter().map(|path| {
342                            Label::new(path.to_string_lossy().into_owned())
343                                .size(LabelSize::Small)
344                                .color(Color::Muted)
345                        }),
346                    )),
347            )
348    }
349}
350
351impl Render for RemoteConnectionModal {
352    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
353        let nickname = self.prompt.read(cx).nickname.clone();
354        let connection_string = self.prompt.read(cx).connection_string.clone();
355        let is_wsl = self.prompt.read(cx).is_wsl;
356        let is_devcontainer = self.prompt.read(cx).is_devcontainer;
357
358        let theme = cx.theme().clone();
359        let body_color = theme.colors().editor_background;
360
361        v_flex()
362            .elevation_3(cx)
363            .w(rems(34.))
364            .border_1()
365            .border_color(theme.colors().border)
366            .key_context("SshConnectionModal")
367            .track_focus(&self.focus_handle(cx))
368            .on_action(cx.listener(Self::dismiss))
369            .on_action(cx.listener(Self::confirm))
370            .child(
371                SshConnectionHeader {
372                    paths: self.paths.clone(),
373                    connection_string,
374                    nickname,
375                    is_wsl,
376                    is_devcontainer,
377                }
378                .render(window, cx),
379            )
380            .child(
381                div()
382                    .w_full()
383                    .bg(body_color)
384                    .border_y_1()
385                    .border_color(theme.colors().border_variant)
386                    .child(self.prompt.clone()),
387            )
388            .child(
389                div().w_full().py_1().child(
390                    ListItem::new("li-devcontainer-go-back")
391                        .inset(true)
392                        .spacing(ui::ListItemSpacing::Sparse)
393                        .start_slot(Icon::new(IconName::Close).color(Color::Muted))
394                        .child(Label::new("Cancel"))
395                        .end_slot(
396                            KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
397                                .size(rems_from_px(12.)),
398                        )
399                        .on_click(cx.listener(|this, _, window, cx| {
400                            this.dismiss(&menu::Cancel, window, cx);
401                        })),
402                ),
403            )
404    }
405}
406
407impl Focusable for RemoteConnectionModal {
408    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
409        self.prompt.read(cx).editor.focus_handle(cx)
410    }
411}
412
413impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
414
415impl ModalView for RemoteConnectionModal {
416    fn on_before_dismiss(
417        &mut self,
418        _window: &mut Window,
419        _: &mut Context<Self>,
420    ) -> DismissDecision {
421        DismissDecision::Dismiss(self.finished)
422    }
423
424    fn fade_out_background(&self) -> bool {
425        true
426    }
427}
428
429#[derive(Clone)]
430pub struct RemoteClientDelegate {
431    window: AnyWindowHandle,
432    ui: WeakEntity<RemoteConnectionPrompt>,
433    known_password: Option<EncryptedPassword>,
434}
435
436impl RemoteClientDelegate {
437    pub fn new(
438        window: AnyWindowHandle,
439        ui: WeakEntity<RemoteConnectionPrompt>,
440        known_password: Option<EncryptedPassword>,
441    ) -> Self {
442        Self {
443            window,
444            ui,
445            known_password,
446        }
447    }
448}
449
450impl remote::RemoteClientDelegate for RemoteClientDelegate {
451    fn ask_password(
452        &self,
453        prompt: String,
454        tx: oneshot::Sender<EncryptedPassword>,
455        cx: &mut AsyncApp,
456    ) {
457        let mut known_password = self.known_password.clone();
458        if let Some(password) = known_password.take() {
459            tx.send(password).ok();
460        } else {
461            self.window
462                .update(cx, |_, window, cx| {
463                    self.ui.update(cx, |modal, cx| {
464                        modal.set_prompt(prompt, tx, window, cx);
465                    })
466                })
467                .ok();
468        }
469    }
470
471    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
472        self.update_status(status, cx)
473    }
474
475    fn download_server_binary_locally(
476        &self,
477        platform: RemotePlatform,
478        release_channel: ReleaseChannel,
479        version: Option<Version>,
480        cx: &mut AsyncApp,
481    ) -> Task<anyhow::Result<PathBuf>> {
482        let this = self.clone();
483        cx.spawn(async move |cx| {
484            AutoUpdater::download_remote_server_release(
485                release_channel,
486                version.clone(),
487                platform.os.as_str(),
488                platform.arch.as_str(),
489                move |status, cx| this.set_status(Some(status), cx),
490                cx,
491            )
492            .await
493            .with_context(|| {
494                format!(
495                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
496                    version
497                        .as_ref()
498                        .map(|v| format!("{}", v))
499                        .unwrap_or("unknown".to_string()),
500                    platform.os,
501                    platform.arch,
502                )
503            })
504        })
505    }
506
507    fn get_download_url(
508        &self,
509        platform: RemotePlatform,
510        release_channel: ReleaseChannel,
511        version: Option<Version>,
512        cx: &mut AsyncApp,
513    ) -> Task<Result<Option<String>>> {
514        cx.spawn(async move |cx| {
515            AutoUpdater::get_remote_server_release_url(
516                release_channel,
517                version,
518                platform.os.as_str(),
519                platform.arch.as_str(),
520                cx,
521            )
522            .await
523        })
524    }
525}
526
527impl RemoteClientDelegate {
528    fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
529        cx.update(|cx| {
530            self.ui
531                .update(cx, |modal, cx| {
532                    modal.set_status(status.map(|s| s.to_string()), cx);
533                })
534                .ok()
535        });
536    }
537}
538
539/// Shows a [`RemoteConnectionModal`] on the given workspace and establishes
540/// a remote connection. This is a convenience wrapper around
541/// [`RemoteConnectionModal`] and [`connect`] suitable for use as the
542/// `connect_remote` callback in [`MultiWorkspace::find_or_create_workspace`].
543///
544/// When the global connection pool already has a live connection for the
545/// given options, the modal is skipped entirely and the connection is
546/// reused silently.
547pub fn connect_with_modal(
548    workspace: &Entity<Workspace>,
549    connection_options: RemoteConnectionOptions,
550    window: &mut Window,
551    cx: &mut App,
552) -> Task<Result<Option<Entity<RemoteClient>>>> {
553    if remote::has_active_connection(&connection_options, cx) {
554        return connect_reusing_pool(connection_options, cx);
555    }
556
557    workspace.update(cx, |workspace, cx| {
558        workspace.toggle_modal(window, cx, |window, cx| {
559            RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
560        });
561        let Some(modal) = workspace.active_modal::<RemoteConnectionModal>(cx) else {
562            return Task::ready(Err(anyhow::anyhow!(
563                "Failed to open remote connection dialog"
564            )));
565        };
566        let prompt = modal.read(cx).prompt.clone();
567        connect(
568            ConnectionIdentifier::setup(),
569            connection_options,
570            prompt,
571            window,
572            cx,
573        )
574    })
575}
576
577/// Creates a [`RemoteClient`] by reusing an existing connection from the
578/// global pool. No interactive UI is shown. This should only be called
579/// when [`remote::has_active_connection`] returns `true`.
580fn connect_reusing_pool(
581    connection_options: RemoteConnectionOptions,
582    cx: &mut App,
583) -> Task<Result<Option<Entity<RemoteClient>>>> {
584    let delegate: Arc<dyn remote::RemoteClientDelegate> = Arc::new(BackgroundRemoteClientDelegate);
585
586    cx.spawn(async move |cx| {
587        let connection = remote::connect(connection_options, delegate.clone(), cx).await?;
588
589        let (_cancel_guard, cancel_rx) = oneshot::channel::<()>();
590        cx.update(|cx| {
591            RemoteClient::new(
592                ConnectionIdentifier::setup(),
593                connection,
594                cancel_rx,
595                delegate,
596                cx,
597            )
598        })
599        .await
600    })
601}
602
603/// Delegate for remote connections that reuse an existing pooled
604/// connection. Password prompts are not expected (the SSH transport
605/// is already established), but server binary downloads are supported
606/// via [`AutoUpdater`].
607struct BackgroundRemoteClientDelegate;
608
609impl remote::RemoteClientDelegate for BackgroundRemoteClientDelegate {
610    fn ask_password(
611        &self,
612        prompt: String,
613        _tx: oneshot::Sender<EncryptedPassword>,
614        _cx: &mut AsyncApp,
615    ) {
616        log::warn!(
617            "Pooled remote connection unexpectedly requires a password \
618             (prompt: {prompt})"
619        );
620    }
621
622    fn set_status(&self, _status: Option<&str>, _cx: &mut AsyncApp) {}
623
624    fn download_server_binary_locally(
625        &self,
626        platform: RemotePlatform,
627        release_channel: ReleaseChannel,
628        version: Option<Version>,
629        cx: &mut AsyncApp,
630    ) -> Task<anyhow::Result<PathBuf>> {
631        cx.spawn(async move |cx| {
632            AutoUpdater::download_remote_server_release(
633                release_channel,
634                version.clone(),
635                platform.os.as_str(),
636                platform.arch.as_str(),
637                |_status, _cx| {},
638                cx,
639            )
640            .await
641            .with_context(|| {
642                format!(
643                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
644                    version
645                        .as_ref()
646                        .map(|v| format!("{v}"))
647                        .unwrap_or("unknown".to_string()),
648                    platform.os,
649                    platform.arch,
650                )
651            })
652        })
653    }
654
655    fn get_download_url(
656        &self,
657        platform: RemotePlatform,
658        release_channel: ReleaseChannel,
659        version: Option<Version>,
660        cx: &mut AsyncApp,
661    ) -> Task<Result<Option<String>>> {
662        cx.spawn(async move |cx| {
663            AutoUpdater::get_remote_server_release_url(
664                release_channel,
665                version,
666                platform.os.as_str(),
667                platform.arch.as_str(),
668                cx,
669            )
670            .await
671        })
672    }
673}
674
675pub fn connect(
676    unique_identifier: ConnectionIdentifier,
677    connection_options: RemoteConnectionOptions,
678    ui: Entity<RemoteConnectionPrompt>,
679    window: &mut Window,
680    cx: &mut App,
681) -> Task<Result<Option<Entity<RemoteClient>>>> {
682    let window = window.window_handle();
683    let known_password = match &connection_options {
684        RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
685            .password
686            .as_deref()
687            .and_then(|pw| pw.try_into().ok()),
688        _ => None,
689    };
690    let (tx, mut rx) = oneshot::channel();
691    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
692
693    let delegate = Arc::new(RemoteClientDelegate {
694        window,
695        ui: ui.downgrade(),
696        known_password,
697    });
698
699    cx.spawn(async move |cx| {
700        let connection = remote::connect(connection_options, delegate.clone(), cx);
701        let connection = select! {
702            _ = rx => return Ok(None),
703            result = connection.fuse() => result,
704        }?;
705
706        cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
707            .await
708    })
709}
710
711use anyhow::Context as _;