remote_connections.rs

  1use std::{path::PathBuf, sync::Arc};
  2
  3use anyhow::{Context as _, Result};
  4use auto_update::AutoUpdater;
  5use editor::Editor;
  6use extension_host::ExtensionStore;
  7use futures::channel::oneshot;
  8use gpui::{
  9    AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
 10    ParentElement as _, PromptLevel, Render, SemanticVersion, SharedString, Task,
 11    TextStyleRefinement, WeakEntity,
 12};
 13
 14use language::CursorShape;
 15use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 16use release_channel::ReleaseChannel;
 17use remote::{
 18    ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform,
 19    SshConnectionOptions,
 20};
 21pub use settings::SshConnection;
 22use settings::{Settings, WslConnection};
 23use theme::ThemeSettings;
 24use ui::{
 25    ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
 26    IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
 27};
 28use util::MergeFrom;
 29use workspace::{AppState, ModalView, Workspace};
 30
 31pub struct SshSettings {
 32    pub ssh_connections: Vec<SshConnection>,
 33    pub wsl_connections: Vec<WslConnection>,
 34    /// Whether to read ~/.ssh/config for ssh connection sources.
 35    pub read_ssh_config: bool,
 36}
 37
 38impl SshSettings {
 39    pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
 40        self.ssh_connections.clone().into_iter()
 41    }
 42
 43    pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
 44        self.wsl_connections.clone().into_iter()
 45    }
 46
 47    pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
 48        for conn in self.ssh_connections() {
 49            if conn.host == options.host
 50                && conn.username == options.username
 51                && conn.port == options.port
 52            {
 53                options.nickname = conn.nickname;
 54                options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
 55                options.args = Some(conn.args);
 56                options.port_forwards = conn.port_forwards;
 57                break;
 58            }
 59        }
 60    }
 61
 62    pub fn connection_options_for(
 63        &self,
 64        host: String,
 65        port: Option<u16>,
 66        username: Option<String>,
 67    ) -> SshConnectionOptions {
 68        let mut options = SshConnectionOptions {
 69            host,
 70            port,
 71            username,
 72            ..Default::default()
 73        };
 74        self.fill_connection_options_from_settings(&mut options);
 75        options
 76    }
 77}
 78
 79#[derive(Clone, PartialEq)]
 80pub enum Connection {
 81    Ssh(SshConnection),
 82    Wsl(WslConnection),
 83}
 84
 85impl From<Connection> for RemoteConnectionOptions {
 86    fn from(val: Connection) -> Self {
 87        match val {
 88            Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
 89            Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
 90        }
 91    }
 92}
 93
 94impl From<SshConnection> for Connection {
 95    fn from(val: SshConnection) -> Self {
 96        Connection::Ssh(val)
 97    }
 98}
 99
100impl From<WslConnection> for Connection {
101    fn from(val: WslConnection) -> Self {
102        Connection::Wsl(val)
103    }
104}
105
106impl Settings for SshSettings {
107    fn from_defaults(content: &settings::SettingsContent, _cx: &mut App) -> Self {
108        let remote = &content.remote;
109        Self {
110            ssh_connections: remote.ssh_connections.clone().unwrap_or_default(),
111            wsl_connections: remote.wsl_connections.clone().unwrap_or_default(),
112            read_ssh_config: remote.read_ssh_config.unwrap(),
113        }
114    }
115
116    fn refine(&mut self, content: &settings::SettingsContent, _cx: &mut App) {
117        if let Some(ssh_connections) = content.remote.ssh_connections.clone() {
118            self.ssh_connections.extend(ssh_connections)
119        }
120        if let Some(wsl_connections) = content.remote.wsl_connections.clone() {
121            self.wsl_connections.extend(wsl_connections)
122        }
123        self.read_ssh_config
124            .merge_from(&content.remote.read_ssh_config);
125    }
126}
127
128pub struct RemoteConnectionPrompt {
129    connection_string: SharedString,
130    nickname: Option<SharedString>,
131    is_wsl: bool,
132    status_message: Option<SharedString>,
133    prompt: Option<(Entity<Markdown>, oneshot::Sender<String>)>,
134    cancellation: Option<oneshot::Sender<()>>,
135    editor: Entity<Editor>,
136}
137
138impl Drop for RemoteConnectionPrompt {
139    fn drop(&mut self) {
140        if let Some(cancel) = self.cancellation.take() {
141            cancel.send(()).ok();
142        }
143    }
144}
145
146pub struct RemoteConnectionModal {
147    pub(crate) prompt: Entity<RemoteConnectionPrompt>,
148    paths: Vec<PathBuf>,
149    finished: bool,
150}
151
152impl RemoteConnectionPrompt {
153    pub(crate) fn new(
154        connection_string: String,
155        nickname: Option<String>,
156        is_wsl: bool,
157        window: &mut Window,
158        cx: &mut Context<Self>,
159    ) -> Self {
160        Self {
161            connection_string: connection_string.into(),
162            nickname: nickname.map(|nickname| nickname.into()),
163            is_wsl,
164            editor: cx.new(|cx| Editor::single_line(window, cx)),
165            status_message: None,
166            cancellation: None,
167            prompt: None,
168        }
169    }
170
171    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
172        self.cancellation = Some(tx);
173    }
174
175    pub fn set_prompt(
176        &mut self,
177        prompt: String,
178        tx: oneshot::Sender<String>,
179        window: &mut Window,
180        cx: &mut Context<Self>,
181    ) {
182        let theme = ThemeSettings::get_global(cx);
183
184        let refinement = TextStyleRefinement {
185            font_family: Some(theme.buffer_font.family.clone()),
186            font_features: Some(FontFeatures::disable_ligatures()),
187            font_size: Some(theme.buffer_font_size(cx).into()),
188            color: Some(cx.theme().colors().editor_foreground),
189            background_color: Some(gpui::transparent_black()),
190            ..Default::default()
191        };
192
193        self.editor.update(cx, |editor, cx| {
194            if prompt.contains("yes/no") {
195                editor.set_masked(false, cx);
196            } else {
197                editor.set_masked(true, cx);
198            }
199            editor.set_text_style_refinement(refinement);
200            editor.set_cursor_shape(CursorShape::Block, cx);
201        });
202
203        let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
204        self.prompt = Some((markdown, tx));
205        self.status_message.take();
206        window.focus(&self.editor.focus_handle(cx));
207        cx.notify();
208    }
209
210    pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
211        self.status_message = status.map(|s| s.into());
212        cx.notify();
213    }
214
215    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<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(editor.text(cx)).ok();
220                editor.clear(window, cx);
221            });
222        }
223    }
224}
225
226impl Render for RemoteConnectionPrompt {
227    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
228        let theme = ThemeSettings::get_global(cx);
229
230        let mut text_style = window.text_style();
231        let refinement = TextStyleRefinement {
232            font_family: Some(theme.buffer_font.family.clone()),
233            font_features: Some(FontFeatures::disable_ligatures()),
234            font_size: Some(theme.buffer_font_size(cx).into()),
235            color: Some(cx.theme().colors().editor_foreground),
236            background_color: Some(gpui::transparent_black()),
237            ..Default::default()
238        };
239
240        text_style.refine(&refinement);
241        let markdown_style = MarkdownStyle {
242            base_text_style: text_style,
243            selection_background_color: cx.theme().colors().element_selection_background,
244            ..Default::default()
245        };
246
247        v_flex()
248            .key_context("PasswordPrompt")
249            .py_2()
250            .px_3()
251            .size_full()
252            .text_buffer(cx)
253            .when_some(self.status_message.clone(), |el, status_message| {
254                el.child(
255                    h_flex()
256                        .gap_1()
257                        .child(
258                            Icon::new(IconName::ArrowCircle)
259                                .size(IconSize::Medium)
260                                .with_rotate_animation(2),
261                        )
262                        .child(
263                            div()
264                                .text_ellipsis()
265                                .overflow_x_hidden()
266                                .child(format!("{}", status_message)),
267                        ),
268                )
269            })
270            .when_some(self.prompt.as_ref(), |el, prompt| {
271                el.child(
272                    div()
273                        .size_full()
274                        .overflow_hidden()
275                        .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
276                        .child(self.editor.clone()),
277                )
278                .when(window.capslock().on, |el| {
279                    el.child(Label::new("⚠️ ⇪ is on"))
280                })
281            })
282    }
283}
284
285impl RemoteConnectionModal {
286    pub(crate) fn new(
287        connection_options: &RemoteConnectionOptions,
288        paths: Vec<PathBuf>,
289        window: &mut Window,
290        cx: &mut Context<Self>,
291    ) -> Self {
292        let (connection_string, nickname, is_wsl) = match connection_options {
293            RemoteConnectionOptions::Ssh(options) => {
294                (options.connection_string(), options.nickname.clone(), false)
295            }
296            RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true),
297        };
298        Self {
299            prompt: cx.new(|cx| {
300                RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx)
301            }),
302            finished: false,
303            paths,
304        }
305    }
306
307    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
308        self.prompt
309            .update(cx, |prompt, cx| prompt.confirm(window, cx))
310    }
311
312    pub fn finished(&mut self, cx: &mut Context<Self>) {
313        self.finished = true;
314        cx.emit(DismissEvent);
315    }
316
317    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
318        if let Some(tx) = self
319            .prompt
320            .update(cx, |prompt, _cx| prompt.cancellation.take())
321        {
322            tx.send(()).ok();
323        }
324        self.finished(cx);
325    }
326}
327
328pub(crate) struct SshConnectionHeader {
329    pub(crate) connection_string: SharedString,
330    pub(crate) paths: Vec<PathBuf>,
331    pub(crate) nickname: Option<SharedString>,
332    pub(crate) is_wsl: bool,
333}
334
335impl RenderOnce for SshConnectionHeader {
336    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
337        let theme = cx.theme();
338
339        let mut header_color = theme.colors().text;
340        header_color.fade_out(0.96);
341
342        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
343            (nickname, Some(format!("({})", self.connection_string)))
344        } else {
345            (self.connection_string, None)
346        };
347
348        let icon = match self.is_wsl {
349            true => IconName::Linux,
350            false => IconName::Server,
351        };
352
353        h_flex()
354            .px(DynamicSpacing::Base12.rems(cx))
355            .pt(DynamicSpacing::Base08.rems(cx))
356            .pb(DynamicSpacing::Base04.rems(cx))
357            .rounded_t_sm()
358            .w_full()
359            .gap_1p5()
360            .child(Icon::new(icon).size(IconSize::Small))
361            .child(
362                h_flex()
363                    .gap_1()
364                    .overflow_x_hidden()
365                    .child(
366                        div()
367                            .max_w_96()
368                            .overflow_x_hidden()
369                            .text_ellipsis()
370                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
371                    )
372                    .children(
373                        meta_label.map(|label| {
374                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
375                        }),
376                    )
377                    .child(div().overflow_x_hidden().text_ellipsis().children(
378                        self.paths.into_iter().map(|path| {
379                            Label::new(path.to_string_lossy().to_string())
380                                .size(LabelSize::Small)
381                                .color(Color::Muted)
382                        }),
383                    )),
384            )
385    }
386}
387
388impl Render for RemoteConnectionModal {
389    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
390        let nickname = self.prompt.read(cx).nickname.clone();
391        let connection_string = self.prompt.read(cx).connection_string.clone();
392        let is_wsl = self.prompt.read(cx).is_wsl;
393
394        let theme = cx.theme().clone();
395        let body_color = theme.colors().editor_background;
396
397        v_flex()
398            .elevation_3(cx)
399            .w(rems(34.))
400            .border_1()
401            .border_color(theme.colors().border)
402            .key_context("SshConnectionModal")
403            .track_focus(&self.focus_handle(cx))
404            .on_action(cx.listener(Self::dismiss))
405            .on_action(cx.listener(Self::confirm))
406            .child(
407                SshConnectionHeader {
408                    paths: self.paths.clone(),
409                    connection_string,
410                    nickname,
411                    is_wsl,
412                }
413                .render(window, cx),
414            )
415            .child(
416                div()
417                    .w_full()
418                    .rounded_b_lg()
419                    .bg(body_color)
420                    .border_t_1()
421                    .border_color(theme.colors().border_variant)
422                    .child(self.prompt.clone()),
423            )
424    }
425}
426
427impl Focusable for RemoteConnectionModal {
428    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
429        self.prompt.read(cx).editor.focus_handle(cx)
430    }
431}
432
433impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
434
435impl ModalView for RemoteConnectionModal {
436    fn on_before_dismiss(
437        &mut self,
438        _window: &mut Window,
439        _: &mut Context<Self>,
440    ) -> workspace::DismissDecision {
441        workspace::DismissDecision::Dismiss(self.finished)
442    }
443
444    fn fade_out_background(&self) -> bool {
445        true
446    }
447}
448
449#[derive(Clone)]
450pub struct RemoteClientDelegate {
451    window: AnyWindowHandle,
452    ui: WeakEntity<RemoteConnectionPrompt>,
453    known_password: Option<String>,
454}
455
456impl remote::RemoteClientDelegate for RemoteClientDelegate {
457    fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
458        let mut known_password = self.known_password.clone();
459        if let Some(password) = known_password.take() {
460            tx.send(password).ok();
461        } else {
462            self.window
463                .update(cx, |_, window, cx| {
464                    self.ui.update(cx, |modal, cx| {
465                        modal.set_prompt(prompt, tx, window, cx);
466                    })
467                })
468                .ok();
469        }
470    }
471
472    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
473        self.update_status(status, cx)
474    }
475
476    fn download_server_binary_locally(
477        &self,
478        platform: RemotePlatform,
479        release_channel: ReleaseChannel,
480        version: Option<SemanticVersion>,
481        cx: &mut AsyncApp,
482    ) -> Task<anyhow::Result<PathBuf>> {
483        cx.spawn(async move |cx| {
484            let binary_path = AutoUpdater::download_remote_server_release(
485                platform.os,
486                platform.arch,
487                release_channel,
488                version,
489                cx,
490            )
491            .await
492            .with_context(|| {
493                format!(
494                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
495                    version
496                        .map(|v| format!("{}", v))
497                        .unwrap_or("unknown".to_string()),
498                    platform.os,
499                    platform.arch,
500                )
501            })?;
502            Ok(binary_path)
503        })
504    }
505
506    fn get_download_params(
507        &self,
508        platform: RemotePlatform,
509        release_channel: ReleaseChannel,
510        version: Option<SemanticVersion>,
511        cx: &mut AsyncApp,
512    ) -> Task<Result<Option<(String, String)>>> {
513        cx.spawn(async move |cx| {
514            AutoUpdater::get_remote_server_release_url(
515                platform.os,
516                platform.arch,
517                release_channel,
518                version,
519                cx,
520            )
521            .await
522        })
523    }
524}
525
526impl RemoteClientDelegate {
527    fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
528        self.window
529            .update(cx, |_, _, cx| {
530                self.ui.update(cx, |modal, cx| {
531                    modal.set_status(status.map(|s| s.to_string()), cx);
532                })
533            })
534            .ok();
535    }
536}
537
538pub fn connect_over_ssh(
539    unique_identifier: ConnectionIdentifier,
540    connection_options: SshConnectionOptions,
541    ui: Entity<RemoteConnectionPrompt>,
542    window: &mut Window,
543    cx: &mut App,
544) -> Task<Result<Option<Entity<RemoteClient>>>> {
545    let window = window.window_handle();
546    let known_password = connection_options.password.clone();
547    let (tx, rx) = oneshot::channel();
548    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
549
550    remote::RemoteClient::ssh(
551        unique_identifier,
552        connection_options,
553        rx,
554        Arc::new(RemoteClientDelegate {
555            window,
556            ui: ui.downgrade(),
557            known_password,
558        }),
559        cx,
560    )
561}
562
563pub fn connect(
564    unique_identifier: ConnectionIdentifier,
565    connection_options: RemoteConnectionOptions,
566    ui: Entity<RemoteConnectionPrompt>,
567    window: &mut Window,
568    cx: &mut App,
569) -> Task<Result<Option<Entity<RemoteClient>>>> {
570    let window = window.window_handle();
571    let known_password = match &connection_options {
572        RemoteConnectionOptions::Ssh(ssh_connection_options) => {
573            ssh_connection_options.password.clone()
574        }
575        _ => None,
576    };
577    let (tx, rx) = oneshot::channel();
578    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
579
580    remote::RemoteClient::new(
581        unique_identifier,
582        connection_options,
583        rx,
584        Arc::new(RemoteClientDelegate {
585            window,
586            ui: ui.downgrade(),
587            known_password,
588        }),
589        cx,
590    )
591}
592
593pub async fn open_remote_project(
594    connection_options: RemoteConnectionOptions,
595    paths: Vec<PathBuf>,
596    app_state: Arc<AppState>,
597    open_options: workspace::OpenOptions,
598    cx: &mut AsyncApp,
599) -> Result<()> {
600    let window = if let Some(window) = open_options.replace_window {
601        window
602    } else {
603        let workspace_position = cx
604            .update(|cx| {
605                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
606            })?
607            .await
608            .context("fetching ssh workspace position from db")?;
609
610        let mut options =
611            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
612        options.window_bounds = workspace_position.window_bounds;
613
614        cx.open_window(options, |window, cx| {
615            let project = project::Project::local(
616                app_state.client.clone(),
617                app_state.node_runtime.clone(),
618                app_state.user_store.clone(),
619                app_state.languages.clone(),
620                app_state.fs.clone(),
621                None,
622                cx,
623            );
624            cx.new(|cx| {
625                let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
626                workspace.centered_layout = workspace_position.centered_layout;
627                workspace
628            })
629        })?
630    };
631
632    loop {
633        let (cancel_tx, cancel_rx) = oneshot::channel();
634        let delegate = window.update(cx, {
635            let paths = paths.clone();
636            let connection_options = connection_options.clone();
637            move |workspace, window, cx| {
638                window.activate_window();
639                workspace.toggle_modal(window, cx, |window, cx| {
640                    RemoteConnectionModal::new(&connection_options, paths, window, cx)
641                });
642
643                let ui = workspace
644                    .active_modal::<RemoteConnectionModal>(cx)?
645                    .read(cx)
646                    .prompt
647                    .clone();
648
649                ui.update(cx, |ui, _cx| {
650                    ui.set_cancellation_tx(cancel_tx);
651                });
652
653                Some(Arc::new(RemoteClientDelegate {
654                    window: window.window_handle(),
655                    ui: ui.downgrade(),
656                    known_password: if let RemoteConnectionOptions::Ssh(options) =
657                        &connection_options
658                    {
659                        options.password.clone()
660                    } else {
661                        None
662                    },
663                }))
664            }
665        })?;
666
667        let Some(delegate) = delegate else { break };
668
669        let did_open_project = cx
670            .update(|cx| {
671                workspace::open_remote_project_with_new_connection(
672                    window,
673                    connection_options.clone(),
674                    cancel_rx,
675                    delegate.clone(),
676                    app_state.clone(),
677                    paths.clone(),
678                    cx,
679                )
680            })?
681            .await;
682
683        window
684            .update(cx, |workspace, _, cx| {
685                if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
686                    ui.update(cx, |modal, cx| modal.finished(cx))
687                }
688            })
689            .ok();
690
691        if let Err(e) = did_open_project {
692            log::error!("Failed to open project: {e:?}");
693            let response = window
694                .update(cx, |_, window, cx| {
695                    window.prompt(
696                        PromptLevel::Critical,
697                        match connection_options {
698                            RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
699                            RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
700                        },
701                        Some(&e.to_string()),
702                        &["Retry", "Ok"],
703                        cx,
704                    )
705                })?
706                .await;
707
708            if response == Ok(0) {
709                continue;
710            }
711        }
712
713        window
714            .update(cx, |workspace, _, cx| {
715                if let Some(client) = workspace.project().read(cx).remote_client() {
716                    ExtensionStore::global(cx)
717                        .update(cx, |store, cx| store.register_remote_client(client, cx));
718                }
719            })
720            .ok();
721
722        break;
723    }
724
725    // Already showed the error to the user
726    Ok(())
727}