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