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