ssh_connections.rs

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