ssh_connections.rs

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