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::ThemeSettings;
 17use ui::{
 18    ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
 19    LabelCommon, ListItem, Styled, Window, prelude::*,
 20};
 21use ui_input::{ERASED_EDITOR_FACTORY, ErasedEditor};
 22use workspace::{DismissDecision, ModalView};
 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}
 34
 35impl Drop for RemoteConnectionPrompt {
 36    fn drop(&mut self) {
 37        if let Some(cancel) = self.cancellation.take() {
 38            log::debug!("cancelling remote connection");
 39            cancel.send(()).ok();
 40        }
 41    }
 42}
 43
 44pub struct RemoteConnectionModal {
 45    pub prompt: Entity<RemoteConnectionPrompt>,
 46    paths: Vec<PathBuf>,
 47    finished: bool,
 48}
 49
 50impl RemoteConnectionPrompt {
 51    pub fn new(
 52        connection_string: String,
 53        nickname: Option<String>,
 54        is_wsl: bool,
 55        is_devcontainer: bool,
 56        window: &mut Window,
 57        cx: &mut Context<Self>,
 58    ) -> Self {
 59        let editor_factory = ERASED_EDITOR_FACTORY
 60            .get()
 61            .expect("ErasedEditorFactory to be initialized");
 62        let editor = (editor_factory)(window, cx);
 63
 64        Self {
 65            connection_string: connection_string.into(),
 66            nickname: nickname.map(|nickname| nickname.into()),
 67            is_wsl,
 68            is_devcontainer,
 69            editor,
 70            status_message: None,
 71            cancellation: None,
 72            prompt: None,
 73        }
 74    }
 75
 76    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
 77        self.cancellation = Some(tx);
 78    }
 79
 80    pub fn set_prompt(
 81        &mut self,
 82        prompt: String,
 83        tx: oneshot::Sender<EncryptedPassword>,
 84        window: &mut Window,
 85        cx: &mut Context<Self>,
 86    ) {
 87        let is_yes_no = prompt.contains("yes/no");
 88        self.editor.set_masked(!is_yes_no, window, cx);
 89
 90        let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
 91        self.prompt = Some((markdown, tx));
 92        self.status_message.take();
 93        window.focus(&self.editor.focus_handle(cx), cx);
 94        cx.notify();
 95    }
 96
 97    pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
 98        self.status_message = status.map(|s| s.into());
 99        cx.notify();
100    }
101
102    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
103        if let Some((_, tx)) = self.prompt.take() {
104            self.status_message = Some("Connecting".into());
105
106            let pw = self.editor.text(cx);
107            if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
108                tx.send(secure).ok();
109            }
110            self.editor.clear(window, cx);
111        }
112    }
113}
114
115impl Render for RemoteConnectionPrompt {
116    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
117        let theme = ThemeSettings::get_global(cx);
118
119        let mut text_style = window.text_style();
120        let refinement = TextStyleRefinement {
121            font_family: Some(theme.buffer_font.family.clone()),
122            font_features: Some(FontFeatures::disable_ligatures()),
123            font_size: Some(theme.buffer_font_size(cx).into()),
124            color: Some(cx.theme().colors().editor_foreground),
125            background_color: Some(gpui::transparent_black()),
126            ..Default::default()
127        };
128
129        text_style.refine(&refinement);
130        let markdown_style = MarkdownStyle {
131            base_text_style: text_style,
132            selection_background_color: cx.theme().colors().element_selection_background,
133            ..Default::default()
134        };
135
136        v_flex()
137            .key_context("PasswordPrompt")
138            .p_2()
139            .size_full()
140            .text_buffer(cx)
141            .when_some(self.status_message.clone(), |el, status_message| {
142                el.child(
143                    h_flex()
144                        .gap_2()
145                        .child(
146                            Icon::new(IconName::ArrowCircle)
147                                .color(Color::Muted)
148                                .with_rotate_animation(2),
149                        )
150                        .child(
151                            div()
152                                .text_ellipsis()
153                                .overflow_x_hidden()
154                                .child(format!("{}", status_message)),
155                        ),
156                )
157            })
158            .when_some(self.prompt.as_ref(), |el, prompt| {
159                el.child(
160                    div()
161                        .size_full()
162                        .overflow_hidden()
163                        .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
164                        .child(self.editor.render(window, cx)),
165                )
166                .when(window.capslock().on, |el| {
167                    el.child(Label::new("⚠️ ⇪ is on"))
168                })
169            })
170    }
171}
172
173impl RemoteConnectionModal {
174    pub fn new(
175        connection_options: &RemoteConnectionOptions,
176        paths: Vec<PathBuf>,
177        window: &mut Window,
178        cx: &mut Context<Self>,
179    ) -> Self {
180        let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
181            RemoteConnectionOptions::Ssh(options) => (
182                options.connection_string(),
183                options.nickname.clone(),
184                false,
185                false,
186            ),
187            RemoteConnectionOptions::Wsl(options) => {
188                (options.distro_name.clone(), None, true, false)
189            }
190            RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
191            #[cfg(any(test, feature = "test-support"))]
192            RemoteConnectionOptions::Mock(options) => {
193                (format!("mock-{}", options.id), None, false, false)
194            }
195        };
196        Self {
197            prompt: cx.new(|cx| {
198                RemoteConnectionPrompt::new(
199                    connection_string,
200                    nickname,
201                    is_wsl,
202                    is_devcontainer,
203                    window,
204                    cx,
205                )
206            }),
207            finished: false,
208            paths,
209        }
210    }
211
212    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
213        self.prompt
214            .update(cx, |prompt, cx| prompt.confirm(window, cx))
215    }
216
217    pub fn finished(&mut self, cx: &mut Context<Self>) {
218        self.finished = true;
219        cx.emit(DismissEvent);
220    }
221
222    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
223        if let Some(tx) = self
224            .prompt
225            .update(cx, |prompt, _cx| prompt.cancellation.take())
226        {
227            log::debug!("cancelling remote connection");
228            tx.send(()).ok();
229        }
230        self.finished(cx);
231    }
232}
233
234pub struct SshConnectionHeader {
235    pub connection_string: SharedString,
236    pub paths: Vec<PathBuf>,
237    pub nickname: Option<SharedString>,
238    pub is_wsl: bool,
239    pub is_devcontainer: bool,
240}
241
242impl RenderOnce for SshConnectionHeader {
243    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
244        let theme = cx.theme();
245
246        let mut header_color = theme.colors().text;
247        header_color.fade_out(0.96);
248
249        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
250            (nickname, Some(format!("({})", self.connection_string)))
251        } else {
252            (self.connection_string, None)
253        };
254
255        let icon = if self.is_wsl {
256            IconName::Linux
257        } else if self.is_devcontainer {
258            IconName::Box
259        } else {
260            IconName::Server
261        };
262
263        h_flex()
264            .px(DynamicSpacing::Base12.rems(cx))
265            .pt(DynamicSpacing::Base08.rems(cx))
266            .pb(DynamicSpacing::Base04.rems(cx))
267            .rounded_t_sm()
268            .w_full()
269            .gap_1p5()
270            .child(Icon::new(icon).size(IconSize::Small))
271            .child(
272                h_flex()
273                    .gap_1()
274                    .overflow_x_hidden()
275                    .child(
276                        div()
277                            .max_w_96()
278                            .overflow_x_hidden()
279                            .text_ellipsis()
280                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
281                    )
282                    .children(
283                        meta_label.map(|label| {
284                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
285                        }),
286                    )
287                    .child(div().overflow_x_hidden().text_ellipsis().children(
288                        self.paths.into_iter().map(|path| {
289                            Label::new(path.to_string_lossy().into_owned())
290                                .size(LabelSize::Small)
291                                .color(Color::Muted)
292                        }),
293                    )),
294            )
295    }
296}
297
298impl Render for RemoteConnectionModal {
299    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
300        let nickname = self.prompt.read(cx).nickname.clone();
301        let connection_string = self.prompt.read(cx).connection_string.clone();
302        let is_wsl = self.prompt.read(cx).is_wsl;
303        let is_devcontainer = self.prompt.read(cx).is_devcontainer;
304
305        let theme = cx.theme().clone();
306        let body_color = theme.colors().editor_background;
307
308        v_flex()
309            .elevation_3(cx)
310            .w(rems(34.))
311            .border_1()
312            .border_color(theme.colors().border)
313            .key_context("SshConnectionModal")
314            .track_focus(&self.focus_handle(cx))
315            .on_action(cx.listener(Self::dismiss))
316            .on_action(cx.listener(Self::confirm))
317            .child(
318                SshConnectionHeader {
319                    paths: self.paths.clone(),
320                    connection_string,
321                    nickname,
322                    is_wsl,
323                    is_devcontainer,
324                }
325                .render(window, cx),
326            )
327            .child(
328                div()
329                    .w_full()
330                    .bg(body_color)
331                    .border_y_1()
332                    .border_color(theme.colors().border_variant)
333                    .child(self.prompt.clone()),
334            )
335            .child(
336                div().w_full().py_1().child(
337                    ListItem::new("li-devcontainer-go-back")
338                        .inset(true)
339                        .spacing(ui::ListItemSpacing::Sparse)
340                        .start_slot(Icon::new(IconName::Close).color(Color::Muted))
341                        .child(Label::new("Cancel"))
342                        .end_slot(
343                            KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
344                                .size(rems_from_px(12.)),
345                        )
346                        .on_click(cx.listener(|this, _, window, cx| {
347                            this.dismiss(&menu::Cancel, window, cx);
348                        })),
349                ),
350            )
351    }
352}
353
354impl Focusable for RemoteConnectionModal {
355    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
356        self.prompt.read(cx).editor.focus_handle(cx)
357    }
358}
359
360impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
361
362impl ModalView for RemoteConnectionModal {
363    fn on_before_dismiss(
364        &mut self,
365        _window: &mut Window,
366        _: &mut Context<Self>,
367    ) -> DismissDecision {
368        DismissDecision::Dismiss(self.finished)
369    }
370
371    fn fade_out_background(&self) -> bool {
372        true
373    }
374}
375
376#[derive(Clone)]
377pub struct RemoteClientDelegate {
378    window: AnyWindowHandle,
379    ui: WeakEntity<RemoteConnectionPrompt>,
380    known_password: Option<EncryptedPassword>,
381}
382
383impl RemoteClientDelegate {
384    pub fn new(
385        window: AnyWindowHandle,
386        ui: WeakEntity<RemoteConnectionPrompt>,
387        known_password: Option<EncryptedPassword>,
388    ) -> Self {
389        Self {
390            window,
391            ui,
392            known_password,
393        }
394    }
395}
396
397impl remote::RemoteClientDelegate for RemoteClientDelegate {
398    fn ask_password(
399        &self,
400        prompt: String,
401        tx: oneshot::Sender<EncryptedPassword>,
402        cx: &mut AsyncApp,
403    ) {
404        let mut known_password = self.known_password.clone();
405        if let Some(password) = known_password.take() {
406            tx.send(password).ok();
407        } else {
408            self.window
409                .update(cx, |_, window, cx| {
410                    self.ui.update(cx, |modal, cx| {
411                        modal.set_prompt(prompt, tx, window, cx);
412                    })
413                })
414                .ok();
415        }
416    }
417
418    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
419        self.update_status(status, cx)
420    }
421
422    fn download_server_binary_locally(
423        &self,
424        platform: RemotePlatform,
425        release_channel: ReleaseChannel,
426        version: Option<Version>,
427        cx: &mut AsyncApp,
428    ) -> Task<anyhow::Result<PathBuf>> {
429        let this = self.clone();
430        cx.spawn(async move |cx| {
431            AutoUpdater::download_remote_server_release(
432                release_channel,
433                version.clone(),
434                platform.os.as_str(),
435                platform.arch.as_str(),
436                move |status, cx| this.set_status(Some(status), cx),
437                cx,
438            )
439            .await
440            .with_context(|| {
441                format!(
442                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
443                    version
444                        .as_ref()
445                        .map(|v| format!("{}", v))
446                        .unwrap_or("unknown".to_string()),
447                    platform.os,
448                    platform.arch,
449                )
450            })
451        })
452    }
453
454    fn get_download_url(
455        &self,
456        platform: RemotePlatform,
457        release_channel: ReleaseChannel,
458        version: Option<Version>,
459        cx: &mut AsyncApp,
460    ) -> Task<Result<Option<String>>> {
461        cx.spawn(async move |cx| {
462            AutoUpdater::get_remote_server_release_url(
463                release_channel,
464                version,
465                platform.os.as_str(),
466                platform.arch.as_str(),
467                cx,
468            )
469            .await
470        })
471    }
472}
473
474impl RemoteClientDelegate {
475    fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
476        cx.update(|cx| {
477            self.ui
478                .update(cx, |modal, cx| {
479                    modal.set_status(status.map(|s| s.to_string()), cx);
480                })
481                .ok()
482        });
483    }
484}
485
486pub fn connect(
487    unique_identifier: ConnectionIdentifier,
488    connection_options: RemoteConnectionOptions,
489    ui: Entity<RemoteConnectionPrompt>,
490    window: &mut Window,
491    cx: &mut App,
492) -> Task<Result<Option<Entity<RemoteClient>>>> {
493    let window = window.window_handle();
494    let known_password = match &connection_options {
495        RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
496            .password
497            .as_deref()
498            .and_then(|pw| pw.try_into().ok()),
499        _ => None,
500    };
501    let (tx, mut rx) = oneshot::channel();
502    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
503
504    let delegate = Arc::new(RemoteClientDelegate {
505        window,
506        ui: ui.downgrade(),
507        known_password,
508    });
509
510    cx.spawn(async move |cx| {
511        let connection = remote::connect(connection_options, delegate.clone(), cx);
512        let connection = select! {
513            _ = rx => return Ok(None),
514            result = connection.fuse() => result,
515        }?;
516
517        cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
518            .await
519    })
520}
521
522use anyhow::Context as _;