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