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