remote_connections.rs

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