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