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 futures::channel::oneshot;
  8use gpui::{
  9    percentage, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
 10    EventEmitter, FocusableView, FontFeatures, ParentElement as _, PromptLevel, Render,
 11    SemanticVersion, SharedString, Task, TextStyleRefinement, Transformation, View, WeakView,
 12};
 13use gpui::{AppContext, Model};
 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, Icon, IconName, IconSize, InteractiveElement, IntoElement,
 26    Label, LabelCommon, Styled, ViewContext, VisualContext, WindowContext,
 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 AppContext) -> 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<(View<Markdown>, oneshot::Sender<Result<String>>)>,
130    cancellation: Option<oneshot::Sender<()>>,
131    editor: View<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: View<SshPrompt>,
144    paths: Vec<PathBuf>,
145    finished: bool,
146}
147
148impl SshPrompt {
149    pub(crate) fn new(
150        connection_options: &SshConnectionOptions,
151        cx: &mut ViewContext<Self>,
152    ) -> Self {
153        let connection_string = connection_options.connection_string().into();
154        let nickname = connection_options.nickname.clone().map(|s| s.into());
155
156        Self {
157            connection_string,
158            nickname,
159            editor: cx.new_view(Editor::single_line),
160            status_message: None,
161            cancellation: None,
162            prompt: None,
163        }
164    }
165
166    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
167        self.cancellation = Some(tx);
168    }
169
170    pub fn set_prompt(
171        &mut self,
172        prompt: String,
173        tx: oneshot::Sender<Result<String>>,
174        cx: &mut ViewContext<Self>,
175    ) {
176        let theme = ThemeSettings::get_global(cx);
177
178        let mut text_style = cx.text_style();
179        let refinement = TextStyleRefinement {
180            font_family: Some(theme.buffer_font.family.clone()),
181            font_features: Some(FontFeatures::disable_ligatures()),
182            font_size: Some(theme.buffer_font_size.into()),
183            color: Some(cx.theme().colors().editor_foreground),
184            background_color: Some(gpui::transparent_black()),
185            ..Default::default()
186        };
187
188        text_style.refine(&refinement);
189        self.editor.update(cx, |editor, cx| {
190            if prompt.contains("yes/no") {
191                editor.set_masked(false, cx);
192            } else {
193                editor.set_masked(true, cx);
194            }
195            editor.set_text_style_refinement(refinement);
196            editor.set_cursor_shape(CursorShape::Block, cx);
197        });
198        let markdown_style = MarkdownStyle {
199            base_text_style: text_style,
200            selection_background_color: cx.theme().players().local().selection,
201            ..Default::default()
202        };
203        let markdown = cx.new_view(|cx| Markdown::new_text(prompt, markdown_style, None, cx, None));
204        self.prompt = Some((markdown, tx));
205        self.status_message.take();
206        cx.focus_view(&self.editor);
207        cx.notify();
208    }
209
210    pub fn set_status(&mut self, status: Option<String>, cx: &mut ViewContext<Self>) {
211        self.status_message = status.map(|s| s.into());
212        cx.notify();
213    }
214
215    pub fn confirm(&mut self, cx: &mut ViewContext<Self>) {
216        if let Some((_, tx)) = self.prompt.take() {
217            self.status_message = Some("Connecting".into());
218            self.editor.update(cx, |editor, cx| {
219                tx.send(Ok(editor.text(cx))).ok();
220                editor.clear(cx);
221            });
222        }
223    }
224}
225
226impl Render for SshPrompt {
227    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
228        let cx = cx.window_context();
229
230        v_flex()
231            .key_context("PasswordPrompt")
232            .py_2()
233            .px_3()
234            .size_full()
235            .text_buffer(cx)
236            .when_some(self.status_message.clone(), |el, status_message| {
237                el.child(
238                    h_flex()
239                        .gap_1()
240                        .child(
241                            Icon::new(IconName::ArrowCircle)
242                                .size(IconSize::Medium)
243                                .with_animation(
244                                    "arrow-circle",
245                                    Animation::new(Duration::from_secs(2)).repeat(),
246                                    |icon, delta| {
247                                        icon.transform(Transformation::rotate(percentage(delta)))
248                                    },
249                                ),
250                        )
251                        .child(
252                            div()
253                                .text_ellipsis()
254                                .overflow_x_hidden()
255                                .child(format!("{}", status_message)),
256                        ),
257                )
258            })
259            .when_some(self.prompt.as_ref(), |el, prompt| {
260                el.child(
261                    div()
262                        .size_full()
263                        .overflow_hidden()
264                        .child(prompt.0.clone())
265                        .child(self.editor.clone()),
266                )
267            })
268    }
269}
270
271impl SshConnectionModal {
272    pub(crate) fn new(
273        connection_options: &SshConnectionOptions,
274        paths: Vec<PathBuf>,
275        cx: &mut ViewContext<Self>,
276    ) -> Self {
277        Self {
278            prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
279            finished: false,
280            paths,
281        }
282    }
283
284    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
285        self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
286    }
287
288    pub fn finished(&mut self, cx: &mut ViewContext<Self>) {
289        self.finished = true;
290        cx.emit(DismissEvent);
291    }
292
293    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
294        if let Some(tx) = self
295            .prompt
296            .update(cx, |prompt, _cx| prompt.cancellation.take())
297        {
298            tx.send(()).ok();
299        }
300        self.finished(cx);
301    }
302}
303
304pub(crate) struct SshConnectionHeader {
305    pub(crate) connection_string: SharedString,
306    pub(crate) paths: Vec<PathBuf>,
307    pub(crate) nickname: Option<SharedString>,
308}
309
310impl RenderOnce for SshConnectionHeader {
311    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
312        let theme = cx.theme();
313
314        let mut header_color = theme.colors().text;
315        header_color.fade_out(0.96);
316
317        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
318            (nickname, Some(format!("({})", self.connection_string)))
319        } else {
320            (self.connection_string, None)
321        };
322
323        h_flex()
324            .px(DynamicSpacing::Base12.rems(cx))
325            .pt(DynamicSpacing::Base08.rems(cx))
326            .pb(DynamicSpacing::Base04.rems(cx))
327            .rounded_t_md()
328            .w_full()
329            .gap_1p5()
330            .child(Icon::new(IconName::Server).size(IconSize::XSmall))
331            .child(
332                h_flex()
333                    .gap_1()
334                    .overflow_x_hidden()
335                    .child(
336                        div()
337                            .max_w_96()
338                            .overflow_x_hidden()
339                            .text_ellipsis()
340                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
341                    )
342                    .children(
343                        meta_label.map(|label| {
344                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
345                        }),
346                    )
347                    .child(div().overflow_x_hidden().text_ellipsis().children(
348                        self.paths.into_iter().map(|path| {
349                            Label::new(path.to_string_lossy().to_string())
350                                .size(LabelSize::Small)
351                                .color(Color::Muted)
352                        }),
353                    )),
354            )
355    }
356}
357
358impl Render for SshConnectionModal {
359    fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
360        let nickname = self.prompt.read(cx).nickname.clone();
361        let connection_string = self.prompt.read(cx).connection_string.clone();
362
363        let theme = cx.theme().clone();
364        let body_color = theme.colors().editor_background;
365
366        v_flex()
367            .elevation_3(cx)
368            .w(rems(34.))
369            .border_1()
370            .border_color(theme.colors().border)
371            .key_context("SshConnectionModal")
372            .track_focus(&self.focus_handle(cx))
373            .on_action(cx.listener(Self::dismiss))
374            .on_action(cx.listener(Self::confirm))
375            .child(
376                SshConnectionHeader {
377                    paths: self.paths.clone(),
378                    connection_string,
379                    nickname,
380                }
381                .render(cx),
382            )
383            .child(
384                div()
385                    .w_full()
386                    .rounded_b_lg()
387                    .bg(body_color)
388                    .border_t_1()
389                    .border_color(theme.colors().border_variant)
390                    .child(self.prompt.clone()),
391            )
392    }
393}
394
395impl FocusableView for SshConnectionModal {
396    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
397        self.prompt.read(cx).editor.focus_handle(cx)
398    }
399}
400
401impl EventEmitter<DismissEvent> for SshConnectionModal {}
402
403impl ModalView for SshConnectionModal {
404    fn on_before_dismiss(&mut self, _: &mut ViewContext<Self>) -> workspace::DismissDecision {
405        return workspace::DismissDecision::Dismiss(self.finished);
406    }
407
408    fn fade_out_background(&self) -> bool {
409        true
410    }
411}
412
413#[derive(Clone)]
414pub struct SshClientDelegate {
415    window: AnyWindowHandle,
416    ui: WeakView<SshPrompt>,
417    known_password: Option<String>,
418}
419
420impl remote::SshClientDelegate for SshClientDelegate {
421    fn ask_password(
422        &self,
423        prompt: String,
424        cx: &mut AsyncAppContext,
425    ) -> oneshot::Receiver<Result<String>> {
426        let (tx, rx) = oneshot::channel();
427        let mut known_password = self.known_password.clone();
428        if let Some(password) = known_password.take() {
429            tx.send(Ok(password)).ok();
430        } else {
431            self.window
432                .update(cx, |_, cx| {
433                    self.ui.update(cx, |modal, cx| {
434                        modal.set_prompt(prompt, tx, cx);
435                    })
436                })
437                .ok();
438        }
439        rx
440    }
441
442    fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
443        self.update_status(status, cx)
444    }
445
446    fn download_server_binary_locally(
447        &self,
448        platform: SshPlatform,
449        release_channel: ReleaseChannel,
450        version: Option<SemanticVersion>,
451        cx: &mut AsyncAppContext,
452    ) -> Task<anyhow::Result<PathBuf>> {
453        cx.spawn(|mut cx| async move {
454            let binary_path = AutoUpdater::download_remote_server_release(
455                platform.os,
456                platform.arch,
457                release_channel,
458                version,
459                &mut cx,
460            )
461            .await
462            .map_err(|e| {
463                anyhow!(
464                    "Failed to download remote server binary (version: {}, os: {}, arch: {}): {}",
465                    version
466                        .map(|v| format!("{}", v))
467                        .unwrap_or("unknown".to_string()),
468                    platform.os,
469                    platform.arch,
470                    e
471                )
472            })?;
473            Ok(binary_path)
474        })
475    }
476
477    fn get_download_params(
478        &self,
479        platform: SshPlatform,
480        release_channel: ReleaseChannel,
481        version: Option<SemanticVersion>,
482        cx: &mut AsyncAppContext,
483    ) -> Task<Result<Option<(String, String)>>> {
484        cx.spawn(|mut cx| async move {
485            AutoUpdater::get_remote_server_release_url(
486                platform.os,
487                platform.arch,
488                release_channel,
489                version,
490                &mut cx,
491            )
492            .await
493        })
494    }
495}
496
497impl SshClientDelegate {
498    fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
499        self.window
500            .update(cx, |_, cx| {
501                self.ui.update(cx, |modal, cx| {
502                    modal.set_status(status.map(|s| s.to_string()), cx);
503                })
504            })
505            .ok();
506    }
507}
508
509pub fn is_connecting_over_ssh(workspace: &Workspace, cx: &AppContext) -> bool {
510    workspace.active_modal::<SshConnectionModal>(cx).is_some()
511}
512
513pub fn connect_over_ssh(
514    unique_identifier: ConnectionIdentifier,
515    connection_options: SshConnectionOptions,
516    ui: View<SshPrompt>,
517    cx: &mut WindowContext,
518) -> Task<Result<Option<Model<SshRemoteClient>>>> {
519    let window = cx.window_handle();
520    let known_password = connection_options.password.clone();
521    let (tx, rx) = oneshot::channel();
522    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
523
524    remote::SshRemoteClient::new(
525        unique_identifier,
526        connection_options,
527        rx,
528        Arc::new(SshClientDelegate {
529            window,
530            ui: ui.downgrade(),
531            known_password,
532        }),
533        cx,
534    )
535}
536
537pub async fn open_ssh_project(
538    connection_options: SshConnectionOptions,
539    paths: Vec<PathBuf>,
540    app_state: Arc<AppState>,
541    open_options: workspace::OpenOptions,
542    cx: &mut AsyncAppContext,
543) -> Result<()> {
544    let window = if let Some(window) = open_options.replace_window {
545        window
546    } else {
547        let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
548        cx.open_window(options, |cx| {
549            let project = project::Project::local(
550                app_state.client.clone(),
551                app_state.node_runtime.clone(),
552                app_state.user_store.clone(),
553                app_state.languages.clone(),
554                app_state.fs.clone(),
555                None,
556                cx,
557            );
558            cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
559        })?
560    };
561
562    loop {
563        let (cancel_tx, cancel_rx) = oneshot::channel();
564        let delegate = window.update(cx, {
565            let connection_options = connection_options.clone();
566            let paths = paths.clone();
567            move |workspace, cx| {
568                cx.activate_window();
569                workspace.toggle_modal(cx, |cx| {
570                    SshConnectionModal::new(&connection_options, paths, cx)
571                });
572
573                let ui = workspace
574                    .active_modal::<SshConnectionModal>(cx)?
575                    .read(cx)
576                    .prompt
577                    .clone();
578
579                ui.update(cx, |ui, _cx| {
580                    ui.set_cancellation_tx(cancel_tx);
581                });
582
583                Some(Arc::new(SshClientDelegate {
584                    window: cx.window_handle(),
585                    ui: ui.downgrade(),
586                    known_password: connection_options.password.clone(),
587                }))
588            }
589        })?;
590
591        let Some(delegate) = delegate else { break };
592
593        let did_open_ssh_project = cx
594            .update(|cx| {
595                workspace::open_ssh_project(
596                    window,
597                    connection_options.clone(),
598                    cancel_rx,
599                    delegate.clone(),
600                    app_state.clone(),
601                    paths.clone(),
602                    cx,
603                )
604            })?
605            .await;
606
607        window
608            .update(cx, |workspace, cx| {
609                if let Some(ui) = workspace.active_modal::<SshConnectionModal>(cx) {
610                    ui.update(cx, |modal, cx| modal.finished(cx))
611                }
612            })
613            .ok();
614
615        if let Err(e) = did_open_ssh_project {
616            log::error!("Failed to open project: {:?}", e);
617            let response = window
618                .update(cx, |_, cx| {
619                    cx.prompt(
620                        PromptLevel::Critical,
621                        "Failed to connect over SSH",
622                        Some(&e.to_string()),
623                        &["Retry", "Ok"],
624                    )
625                })?
626                .await;
627
628            if response == Ok(0) {
629                continue;
630            }
631        }
632
633        break;
634    }
635
636    // Already showed the error to the user
637    Ok(())
638}