ssh_connections.rs

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