ssh_connections.rs

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