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