remote_connections.rs

   1use std::{
   2    path::{Path, PathBuf},
   3    sync::Arc,
   4};
   5
   6use anyhow::{Context as _, Result};
   7use askpass::EncryptedPassword;
   8use auto_update::AutoUpdater;
   9use editor::Editor;
  10use extension_host::ExtensionStore;
  11use futures::{FutureExt as _, channel::oneshot, select};
  12use gpui::{
  13    AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
  14    ParentElement as _, PromptLevel, Render, SharedString, Task, TextStyleRefinement, WeakEntity,
  15};
  16
  17use language::{CursorShape, Point};
  18use markdown::{Markdown, MarkdownElement, MarkdownStyle};
  19use project::trusted_worktrees;
  20use release_channel::ReleaseChannel;
  21use remote::{
  22    ConnectionIdentifier, DockerConnectionOptions, Interactive, RemoteClient, RemoteConnection,
  23    RemoteConnectionOptions, RemotePlatform, SshConnectionOptions,
  24};
  25use semver::Version;
  26pub use settings::SshConnection;
  27use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
  28use theme::ThemeSettings;
  29use ui::{
  30    ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
  31    LabelCommon, ListItem, Styled, Window, prelude::*,
  32};
  33use util::paths::PathWithPosition;
  34use workspace::{AppState, ModalView, Workspace};
  35
  36#[derive(RegisterSetting)]
  37pub struct RemoteSettings {
  38    pub ssh_connections: ExtendingVec<SshConnection>,
  39    pub wsl_connections: ExtendingVec<WslConnection>,
  40    /// Whether to read ~/.ssh/config for ssh connection sources.
  41    pub read_ssh_config: bool,
  42}
  43
  44impl RemoteSettings {
  45    pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
  46        self.ssh_connections.clone().0.into_iter()
  47    }
  48
  49    pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
  50        self.wsl_connections.clone().0.into_iter()
  51    }
  52
  53    pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
  54        for conn in self.ssh_connections() {
  55            if conn.host == options.host.to_string()
  56                && conn.username == options.username
  57                && conn.port == options.port
  58            {
  59                options.nickname = conn.nickname;
  60                options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
  61                options.args = Some(conn.args);
  62                options.port_forwards = conn.port_forwards;
  63                break;
  64            }
  65        }
  66    }
  67
  68    pub fn connection_options_for(
  69        &self,
  70        host: String,
  71        port: Option<u16>,
  72        username: Option<String>,
  73    ) -> SshConnectionOptions {
  74        let mut options = SshConnectionOptions {
  75            host: host.into(),
  76            port,
  77            username,
  78            ..Default::default()
  79        };
  80        self.fill_connection_options_from_settings(&mut options);
  81        options
  82    }
  83}
  84
  85#[derive(Clone, PartialEq)]
  86pub enum Connection {
  87    Ssh(SshConnection),
  88    Wsl(WslConnection),
  89    DevContainer(DevContainerConnection),
  90}
  91
  92impl From<Connection> for RemoteConnectionOptions {
  93    fn from(val: Connection) -> Self {
  94        match val {
  95            Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
  96            Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
  97            Connection::DevContainer(conn) => {
  98                RemoteConnectionOptions::Docker(DockerConnectionOptions {
  99                    name: conn.name.to_string(),
 100                    container_id: conn.container_id.to_string(),
 101                    upload_binary_over_docker_exec: false,
 102                })
 103            }
 104        }
 105    }
 106}
 107
 108impl From<SshConnection> for Connection {
 109    fn from(val: SshConnection) -> Self {
 110        Connection::Ssh(val)
 111    }
 112}
 113
 114impl From<WslConnection> for Connection {
 115    fn from(val: WslConnection) -> Self {
 116        Connection::Wsl(val)
 117    }
 118}
 119
 120impl Settings for RemoteSettings {
 121    fn from_settings(content: &settings::SettingsContent) -> Self {
 122        let remote = &content.remote;
 123        Self {
 124            ssh_connections: remote.ssh_connections.clone().unwrap_or_default().into(),
 125            wsl_connections: remote.wsl_connections.clone().unwrap_or_default().into(),
 126            read_ssh_config: remote.read_ssh_config.unwrap(),
 127        }
 128    }
 129}
 130
 131pub struct RemoteConnectionPrompt {
 132    connection_string: SharedString,
 133    nickname: Option<SharedString>,
 134    is_wsl: bool,
 135    is_devcontainer: bool,
 136    status_message: Option<SharedString>,
 137    prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
 138    cancellation: Option<oneshot::Sender<()>>,
 139    editor: Entity<Editor>,
 140}
 141
 142impl Drop for RemoteConnectionPrompt {
 143    fn drop(&mut self) {
 144        if let Some(cancel) = self.cancellation.take() {
 145            log::debug!("cancelling remote connection");
 146            cancel.send(()).ok();
 147        }
 148    }
 149}
 150
 151pub struct RemoteConnectionModal {
 152    pub prompt: Entity<RemoteConnectionPrompt>,
 153    paths: Vec<PathBuf>,
 154    finished: bool,
 155}
 156
 157impl RemoteConnectionPrompt {
 158    pub(crate) fn new(
 159        connection_string: String,
 160        nickname: Option<String>,
 161        is_wsl: bool,
 162        is_devcontainer: bool,
 163        window: &mut Window,
 164        cx: &mut Context<Self>,
 165    ) -> Self {
 166        Self {
 167            connection_string: connection_string.into(),
 168            nickname: nickname.map(|nickname| nickname.into()),
 169            is_wsl,
 170            is_devcontainer,
 171            editor: cx.new(|cx| Editor::single_line(window, cx)),
 172            status_message: None,
 173            cancellation: None,
 174            prompt: None,
 175        }
 176    }
 177
 178    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
 179        self.cancellation = Some(tx);
 180    }
 181
 182    fn set_prompt(
 183        &mut self,
 184        prompt: String,
 185        tx: oneshot::Sender<EncryptedPassword>,
 186        window: &mut Window,
 187        cx: &mut Context<Self>,
 188    ) {
 189        let theme = ThemeSettings::get_global(cx);
 190
 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        self.editor.update(cx, |editor, cx| {
 201            if prompt.contains("yes/no") {
 202                editor.set_masked(false, cx);
 203            } else {
 204                editor.set_masked(true, cx);
 205            }
 206            editor.set_text_style_refinement(refinement);
 207            editor.set_cursor_shape(CursorShape::Block, cx);
 208        });
 209
 210        let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
 211        self.prompt = Some((markdown, tx));
 212        self.status_message.take();
 213        window.focus(&self.editor.focus_handle(cx), cx);
 214        cx.notify();
 215    }
 216
 217    pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
 218        self.status_message = status.map(|s| s.into());
 219        cx.notify();
 220    }
 221
 222    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 223        if let Some((_, tx)) = self.prompt.take() {
 224            self.status_message = Some("Connecting".into());
 225
 226            self.editor.update(cx, |editor, cx| {
 227                let pw = editor.text(cx);
 228                if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
 229                    tx.send(secure).ok();
 230                }
 231                editor.clear(window, cx);
 232            });
 233        }
 234    }
 235}
 236
 237impl Render for RemoteConnectionPrompt {
 238    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 239        let theme = ThemeSettings::get_global(cx);
 240
 241        let mut text_style = window.text_style();
 242        let refinement = TextStyleRefinement {
 243            font_family: Some(theme.buffer_font.family.clone()),
 244            font_features: Some(FontFeatures::disable_ligatures()),
 245            font_size: Some(theme.buffer_font_size(cx).into()),
 246            color: Some(cx.theme().colors().editor_foreground),
 247            background_color: Some(gpui::transparent_black()),
 248            ..Default::default()
 249        };
 250
 251        text_style.refine(&refinement);
 252        let markdown_style = MarkdownStyle {
 253            base_text_style: text_style,
 254            selection_background_color: cx.theme().colors().element_selection_background,
 255            ..Default::default()
 256        };
 257
 258        v_flex()
 259            .key_context("PasswordPrompt")
 260            .p_2()
 261            .size_full()
 262            .text_buffer(cx)
 263            .when_some(self.status_message.clone(), |el, status_message| {
 264                el.child(
 265                    h_flex()
 266                        .gap_2()
 267                        .child(
 268                            Icon::new(IconName::ArrowCircle)
 269                                .color(Color::Muted)
 270                                .with_rotate_animation(2),
 271                        )
 272                        .child(
 273                            div()
 274                                .text_ellipsis()
 275                                .overflow_x_hidden()
 276                                .child(format!("{}", status_message)),
 277                        ),
 278                )
 279            })
 280            .when_some(self.prompt.as_ref(), |el, prompt| {
 281                el.child(
 282                    div()
 283                        .size_full()
 284                        .overflow_hidden()
 285                        .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
 286                        .child(self.editor.clone()),
 287                )
 288                .when(window.capslock().on, |el| {
 289                    el.child(Label::new("⚠️ ⇪ is on"))
 290                })
 291            })
 292    }
 293}
 294
 295impl RemoteConnectionModal {
 296    pub fn new(
 297        connection_options: &RemoteConnectionOptions,
 298        paths: Vec<PathBuf>,
 299        window: &mut Window,
 300        cx: &mut Context<Self>,
 301    ) -> Self {
 302        let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
 303            RemoteConnectionOptions::Ssh(options) => (
 304                options.connection_string(),
 305                options.nickname.clone(),
 306                false,
 307                false,
 308            ),
 309            RemoteConnectionOptions::Wsl(options) => {
 310                (options.distro_name.clone(), None, true, false)
 311            }
 312            RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
 313            #[cfg(any(test, feature = "test-support"))]
 314            RemoteConnectionOptions::Mock(options) => {
 315                (format!("mock-{}", options.id), None, false, false)
 316            }
 317        };
 318        Self {
 319            prompt: cx.new(|cx| {
 320                RemoteConnectionPrompt::new(
 321                    connection_string,
 322                    nickname,
 323                    is_wsl,
 324                    is_devcontainer,
 325                    window,
 326                    cx,
 327                )
 328            }),
 329            finished: false,
 330            paths,
 331        }
 332    }
 333
 334    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 335        self.prompt
 336            .update(cx, |prompt, cx| prompt.confirm(window, cx))
 337    }
 338
 339    pub fn finished(&mut self, cx: &mut Context<Self>) {
 340        self.finished = true;
 341        cx.emit(DismissEvent);
 342    }
 343
 344    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
 345        if let Some(tx) = self
 346            .prompt
 347            .update(cx, |prompt, _cx| prompt.cancellation.take())
 348        {
 349            log::debug!("cancelling remote connection");
 350            tx.send(()).ok();
 351        }
 352        self.finished(cx);
 353    }
 354}
 355
 356pub(crate) struct SshConnectionHeader {
 357    pub(crate) connection_string: SharedString,
 358    pub(crate) paths: Vec<PathBuf>,
 359    pub(crate) nickname: Option<SharedString>,
 360    pub(crate) is_wsl: bool,
 361    pub(crate) is_devcontainer: bool,
 362}
 363
 364impl RenderOnce for SshConnectionHeader {
 365    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
 366        let theme = cx.theme();
 367
 368        let mut header_color = theme.colors().text;
 369        header_color.fade_out(0.96);
 370
 371        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
 372            (nickname, Some(format!("({})", self.connection_string)))
 373        } else {
 374            (self.connection_string, None)
 375        };
 376
 377        let icon = if self.is_wsl {
 378            IconName::Linux
 379        } else if self.is_devcontainer {
 380            IconName::Box
 381        } else {
 382            IconName::Server
 383        };
 384
 385        h_flex()
 386            .px(DynamicSpacing::Base12.rems(cx))
 387            .pt(DynamicSpacing::Base08.rems(cx))
 388            .pb(DynamicSpacing::Base04.rems(cx))
 389            .rounded_t_sm()
 390            .w_full()
 391            .gap_1p5()
 392            .child(Icon::new(icon).size(IconSize::Small))
 393            .child(
 394                h_flex()
 395                    .gap_1()
 396                    .overflow_x_hidden()
 397                    .child(
 398                        div()
 399                            .max_w_96()
 400                            .overflow_x_hidden()
 401                            .text_ellipsis()
 402                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
 403                    )
 404                    .children(
 405                        meta_label.map(|label| {
 406                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
 407                        }),
 408                    )
 409                    .child(div().overflow_x_hidden().text_ellipsis().children(
 410                        self.paths.into_iter().map(|path| {
 411                            Label::new(path.to_string_lossy().into_owned())
 412                                .size(LabelSize::Small)
 413                                .color(Color::Muted)
 414                        }),
 415                    )),
 416            )
 417    }
 418}
 419
 420impl Render for RemoteConnectionModal {
 421    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
 422        let nickname = self.prompt.read(cx).nickname.clone();
 423        let connection_string = self.prompt.read(cx).connection_string.clone();
 424        let is_wsl = self.prompt.read(cx).is_wsl;
 425        let is_devcontainer = self.prompt.read(cx).is_devcontainer;
 426
 427        let theme = cx.theme().clone();
 428        let body_color = theme.colors().editor_background;
 429
 430        v_flex()
 431            .elevation_3(cx)
 432            .w(rems(34.))
 433            .border_1()
 434            .border_color(theme.colors().border)
 435            .key_context("SshConnectionModal")
 436            .track_focus(&self.focus_handle(cx))
 437            .on_action(cx.listener(Self::dismiss))
 438            .on_action(cx.listener(Self::confirm))
 439            .child(
 440                SshConnectionHeader {
 441                    paths: self.paths.clone(),
 442                    connection_string,
 443                    nickname,
 444                    is_wsl,
 445                    is_devcontainer,
 446                }
 447                .render(window, cx),
 448            )
 449            .child(
 450                div()
 451                    .w_full()
 452                    .bg(body_color)
 453                    .border_y_1()
 454                    .border_color(theme.colors().border_variant)
 455                    .child(self.prompt.clone()),
 456            )
 457            .child(
 458                div().w_full().py_1().child(
 459                    ListItem::new("li-devcontainer-go-back")
 460                        .inset(true)
 461                        .spacing(ui::ListItemSpacing::Sparse)
 462                        .start_slot(Icon::new(IconName::Close).color(Color::Muted))
 463                        .child(Label::new("Cancel"))
 464                        .end_slot(
 465                            KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
 466                                .size(rems_from_px(12.)),
 467                        )
 468                        .on_click(cx.listener(|this, _, window, cx| {
 469                            this.dismiss(&menu::Cancel, window, cx);
 470                        })),
 471                ),
 472            )
 473    }
 474}
 475
 476impl Focusable for RemoteConnectionModal {
 477    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
 478        self.prompt.read(cx).editor.focus_handle(cx)
 479    }
 480}
 481
 482impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
 483
 484impl ModalView for RemoteConnectionModal {
 485    fn on_before_dismiss(
 486        &mut self,
 487        _window: &mut Window,
 488        _: &mut Context<Self>,
 489    ) -> workspace::DismissDecision {
 490        workspace::DismissDecision::Dismiss(self.finished)
 491    }
 492
 493    fn fade_out_background(&self) -> bool {
 494        true
 495    }
 496}
 497
 498#[derive(Clone)]
 499pub struct RemoteClientDelegate {
 500    window: AnyWindowHandle,
 501    ui: WeakEntity<RemoteConnectionPrompt>,
 502    known_password: Option<EncryptedPassword>,
 503}
 504
 505impl remote::RemoteClientDelegate for RemoteClientDelegate {
 506    fn ask_password(
 507        &self,
 508        prompt: String,
 509        tx: oneshot::Sender<EncryptedPassword>,
 510        cx: &mut AsyncApp,
 511    ) {
 512        let mut known_password = self.known_password.clone();
 513        if let Some(password) = known_password.take() {
 514            tx.send(password).ok();
 515        } else {
 516            self.window
 517                .update(cx, |_, window, cx| {
 518                    self.ui.update(cx, |modal, cx| {
 519                        modal.set_prompt(prompt, tx, window, cx);
 520                    })
 521                })
 522                .ok();
 523        }
 524    }
 525
 526    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
 527        self.update_status(status, cx)
 528    }
 529
 530    fn download_server_binary_locally(
 531        &self,
 532        platform: RemotePlatform,
 533        release_channel: ReleaseChannel,
 534        version: Option<Version>,
 535        cx: &mut AsyncApp,
 536    ) -> Task<anyhow::Result<PathBuf>> {
 537        let this = self.clone();
 538        cx.spawn(async move |cx| {
 539            AutoUpdater::download_remote_server_release(
 540                release_channel,
 541                version.clone(),
 542                platform.os.as_str(),
 543                platform.arch.as_str(),
 544                move |status, cx| this.set_status(Some(status), cx),
 545                cx,
 546            )
 547            .await
 548            .with_context(|| {
 549                format!(
 550                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
 551                    version
 552                        .as_ref()
 553                        .map(|v| format!("{}", v))
 554                        .unwrap_or("unknown".to_string()),
 555                    platform.os,
 556                    platform.arch,
 557                )
 558            })
 559        })
 560    }
 561
 562    fn get_download_url(
 563        &self,
 564        platform: RemotePlatform,
 565        release_channel: ReleaseChannel,
 566        version: Option<Version>,
 567        cx: &mut AsyncApp,
 568    ) -> Task<Result<Option<String>>> {
 569        cx.spawn(async move |cx| {
 570            AutoUpdater::get_remote_server_release_url(
 571                release_channel,
 572                version,
 573                platform.os.as_str(),
 574                platform.arch.as_str(),
 575                cx,
 576            )
 577            .await
 578        })
 579    }
 580}
 581
 582impl RemoteClientDelegate {
 583    fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
 584        cx.update(|cx| {
 585            self.ui
 586                .update(cx, |modal, cx| {
 587                    modal.set_status(status.map(|s| s.to_string()), cx);
 588                })
 589                .ok()
 590        });
 591    }
 592}
 593
 594pub fn connect(
 595    unique_identifier: ConnectionIdentifier,
 596    connection_options: RemoteConnectionOptions,
 597    ui: Entity<RemoteConnectionPrompt>,
 598    window: &mut Window,
 599    cx: &mut App,
 600) -> Task<Result<Option<Entity<RemoteClient>>>> {
 601    let window = window.window_handle();
 602    let known_password = match &connection_options {
 603        RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
 604            .password
 605            .as_deref()
 606            .and_then(|pw| pw.try_into().ok()),
 607        _ => None,
 608    };
 609    let (tx, mut rx) = oneshot::channel();
 610    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
 611
 612    let delegate = Arc::new(RemoteClientDelegate {
 613        window,
 614        ui: ui.downgrade(),
 615        known_password,
 616    });
 617
 618    cx.spawn(async move |cx| {
 619        let connection = remote::connect(connection_options, delegate.clone(), cx);
 620        let connection = select! {
 621            _ = rx => return Ok(None),
 622            result = connection.fuse() => result,
 623        }?;
 624
 625        cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
 626            .await
 627    })
 628}
 629
 630pub async fn open_remote_project(
 631    connection_options: RemoteConnectionOptions,
 632    paths: Vec<PathBuf>,
 633    app_state: Arc<AppState>,
 634    open_options: workspace::OpenOptions,
 635    cx: &mut AsyncApp,
 636) -> Result<()> {
 637    let created_new_window = open_options.replace_window.is_none();
 638    let window = if let Some(window) = open_options.replace_window {
 639        window
 640    } else {
 641        let workspace_position = cx
 642            .update(|cx| {
 643                // todo: These paths are wrong they may have column and line information
 644                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
 645            })
 646            .await
 647            .context("fetching remote workspace position from db")?;
 648
 649        let mut options =
 650            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
 651        options.window_bounds = workspace_position.window_bounds;
 652
 653        cx.open_window(options, |window, cx| {
 654            let project = project::Project::local(
 655                app_state.client.clone(),
 656                app_state.node_runtime.clone(),
 657                app_state.user_store.clone(),
 658                app_state.languages.clone(),
 659                app_state.fs.clone(),
 660                None,
 661                false,
 662                cx,
 663            );
 664            cx.new(|cx| {
 665                let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
 666                workspace.centered_layout = workspace_position.centered_layout;
 667                workspace
 668            })
 669        })?
 670    };
 671
 672    loop {
 673        let (cancel_tx, mut cancel_rx) = oneshot::channel();
 674        let delegate = window.update(cx, {
 675            let paths = paths.clone();
 676            let connection_options = connection_options.clone();
 677            move |workspace, window, cx| {
 678                window.activate_window();
 679                workspace.hide_modal(window, cx);
 680                workspace.toggle_modal(window, cx, |window, cx| {
 681                    RemoteConnectionModal::new(&connection_options, paths, window, cx)
 682                });
 683
 684                let ui = workspace
 685                    .active_modal::<RemoteConnectionModal>(cx)?
 686                    .read(cx)
 687                    .prompt
 688                    .clone();
 689
 690                ui.update(cx, |ui, _cx| {
 691                    ui.set_cancellation_tx(cancel_tx);
 692                });
 693
 694                Some(Arc::new(RemoteClientDelegate {
 695                    window: window.window_handle(),
 696                    ui: ui.downgrade(),
 697                    known_password: if let RemoteConnectionOptions::Ssh(options) =
 698                        &connection_options
 699                    {
 700                        options
 701                            .password
 702                            .as_deref()
 703                            .and_then(|pw| EncryptedPassword::try_from(pw).ok())
 704                    } else {
 705                        None
 706                    },
 707                }))
 708            }
 709        })?;
 710
 711        let Some(delegate) = delegate else { break };
 712
 713        let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
 714        let connection = select! {
 715            _ = cancel_rx => {
 716                window
 717                    .update(cx, |workspace, _, cx| {
 718                        if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
 719                            ui.update(cx, |modal, cx| modal.finished(cx))
 720                        }
 721                    })
 722                    .ok();
 723
 724                break;
 725            },
 726            result = connection.fuse() => result,
 727        };
 728        let remote_connection = match connection {
 729            Ok(connection) => connection,
 730            Err(e) => {
 731                window
 732                    .update(cx, |workspace, _, cx| {
 733                        if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
 734                            ui.update(cx, |modal, cx| modal.finished(cx))
 735                        }
 736                    })
 737                    .ok();
 738                log::error!("Failed to open project: {e:#}");
 739                let response = window
 740                    .update(cx, |_, window, cx| {
 741                        window.prompt(
 742                            PromptLevel::Critical,
 743                            match connection_options {
 744                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
 745                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
 746                                RemoteConnectionOptions::Docker(_) => {
 747                                    "Failed to connect to Dev Container"
 748                                }
 749                                #[cfg(any(test, feature = "test-support"))]
 750                                RemoteConnectionOptions::Mock(_) => {
 751                                    "Failed to connect to mock server"
 752                                }
 753                            },
 754                            Some(&format!("{e:#}")),
 755                            &["Retry", "Cancel"],
 756                            cx,
 757                        )
 758                    })?
 759                    .await;
 760
 761                if response == Ok(0) {
 762                    continue;
 763                }
 764
 765                if created_new_window {
 766                    window
 767                        .update(cx, |_, window, _| window.remove_window())
 768                        .ok();
 769                }
 770                return Ok(());
 771            }
 772        };
 773
 774        let (paths, paths_with_positions) =
 775            determine_paths_with_positions(&remote_connection, paths.clone()).await;
 776
 777        let opened_items = cx
 778            .update(|cx| {
 779                workspace::open_remote_project_with_new_connection(
 780                    window,
 781                    remote_connection,
 782                    cancel_rx,
 783                    delegate.clone(),
 784                    app_state.clone(),
 785                    paths.clone(),
 786                    cx,
 787                )
 788            })
 789            .await;
 790
 791        window
 792            .update(cx, |workspace, _, cx| {
 793                if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
 794                    ui.update(cx, |modal, cx| modal.finished(cx))
 795                }
 796            })
 797            .ok();
 798
 799        match opened_items {
 800            Err(e) => {
 801                log::error!("Failed to open project: {e:#}");
 802                let response = window
 803                    .update(cx, |_, window, cx| {
 804                        window.prompt(
 805                            PromptLevel::Critical,
 806                            match connection_options {
 807                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
 808                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
 809                                RemoteConnectionOptions::Docker(_) => {
 810                                    "Failed to connect to Dev Container"
 811                                }
 812                                #[cfg(any(test, feature = "test-support"))]
 813                                RemoteConnectionOptions::Mock(_) => {
 814                                    "Failed to connect to mock server"
 815                                }
 816                            },
 817                            Some(&format!("{e:#}")),
 818                            &["Retry", "Cancel"],
 819                            cx,
 820                        )
 821                    })?
 822                    .await;
 823                if response == Ok(0) {
 824                    continue;
 825                }
 826
 827                window
 828                    .update(cx, |workspace, window, cx| {
 829                        if created_new_window {
 830                            window.remove_window();
 831                        }
 832                        trusted_worktrees::track_worktree_trust(
 833                            workspace.project().read(cx).worktree_store(),
 834                            None,
 835                            None,
 836                            None,
 837                            cx,
 838                        );
 839                    })
 840                    .ok();
 841            }
 842
 843            Ok(items) => {
 844                for (item, path) in items.into_iter().zip(paths_with_positions) {
 845                    let Some(item) = item else {
 846                        continue;
 847                    };
 848                    let Some(row) = path.row else {
 849                        continue;
 850                    };
 851                    if let Some(active_editor) = item.downcast::<Editor>() {
 852                        window
 853                            .update(cx, |_, window, cx| {
 854                                active_editor.update(cx, |editor, cx| {
 855                                    let row = row.saturating_sub(1);
 856                                    let col = path.column.unwrap_or(0).saturating_sub(1);
 857                                    editor.go_to_singleton_buffer_point(
 858                                        Point::new(row, col),
 859                                        window,
 860                                        cx,
 861                                    );
 862                                });
 863                            })
 864                            .ok();
 865                    }
 866                }
 867            }
 868        }
 869
 870        break;
 871    }
 872
 873    window
 874        .update(cx, |workspace, _, cx| {
 875            if let Some(client) = workspace.project().read(cx).remote_client() {
 876                if let Some(extension_store) = ExtensionStore::try_global(cx) {
 877                    extension_store
 878                        .update(cx, |store, cx| store.register_remote_client(client, cx));
 879                }
 880            }
 881        })
 882        .ok();
 883    // Already showed the error to the user
 884    Ok(())
 885}
 886
 887pub(crate) async fn determine_paths_with_positions(
 888    remote_connection: &Arc<dyn RemoteConnection>,
 889    mut paths: Vec<PathBuf>,
 890) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
 891    let mut paths_with_positions = Vec::<PathWithPosition>::new();
 892    for path in &mut paths {
 893        if let Some(path_str) = path.to_str() {
 894            let path_with_position = PathWithPosition::parse_str(&path_str);
 895            if path_with_position.row.is_some() {
 896                if !path_exists(&remote_connection, &path).await {
 897                    *path = path_with_position.path.clone();
 898                    paths_with_positions.push(path_with_position);
 899                    continue;
 900                }
 901            }
 902        }
 903        paths_with_positions.push(PathWithPosition::from_path(path.clone()))
 904    }
 905    (paths, paths_with_positions)
 906}
 907
 908async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
 909    let Ok(command) = connection.build_command(
 910        Some("test".to_string()),
 911        &["-e".to_owned(), path.to_string_lossy().to_string()],
 912        &Default::default(),
 913        None,
 914        None,
 915        Interactive::No,
 916    ) else {
 917        return false;
 918    };
 919    let Ok(mut child) = util::command::new_smol_command(command.program)
 920        .args(command.args)
 921        .envs(command.env)
 922        .spawn()
 923    else {
 924        return false;
 925    };
 926    child.status().await.is_ok_and(|status| status.success())
 927}
 928
 929#[cfg(test)]
 930mod tests {
 931    use super::*;
 932    use extension::ExtensionHostProxy;
 933    use fs::FakeFs;
 934    use gpui::TestAppContext;
 935    use http_client::BlockedHttpClient;
 936    use node_runtime::NodeRuntime;
 937    use remote::RemoteClient;
 938    use remote_server::{HeadlessAppState, HeadlessProject};
 939    use serde_json::json;
 940    use util::path;
 941
 942    #[gpui::test]
 943    async fn test_open_remote_project_with_mock_connection(
 944        cx: &mut TestAppContext,
 945        server_cx: &mut TestAppContext,
 946    ) {
 947        let app_state = init_test(cx);
 948        let executor = cx.executor();
 949
 950        cx.update(|cx| {
 951            release_channel::init(semver::Version::new(0, 0, 0), cx);
 952        });
 953        server_cx.update(|cx| {
 954            release_channel::init(semver::Version::new(0, 0, 0), cx);
 955        });
 956
 957        let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
 958
 959        let remote_fs = FakeFs::new(server_cx.executor());
 960        remote_fs
 961            .insert_tree(
 962                path!("/project"),
 963                json!({
 964                    "src": {
 965                        "main.rs": "fn main() {}",
 966                    },
 967                    "README.md": "# Test Project",
 968                }),
 969            )
 970            .await;
 971
 972        server_cx.update(HeadlessProject::init);
 973        let http_client = Arc::new(BlockedHttpClient);
 974        let node_runtime = NodeRuntime::unavailable();
 975        let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
 976        let proxy = Arc::new(ExtensionHostProxy::new());
 977
 978        let _headless = server_cx.new(|cx| {
 979            HeadlessProject::new(
 980                HeadlessAppState {
 981                    session: server_session,
 982                    fs: remote_fs.clone(),
 983                    http_client,
 984                    node_runtime,
 985                    languages,
 986                    extension_host_proxy: proxy,
 987                },
 988                false,
 989                cx,
 990            )
 991        });
 992
 993        drop(connect_guard);
 994
 995        let paths = vec![PathBuf::from(path!("/project"))];
 996        let open_options = workspace::OpenOptions::default();
 997
 998        let mut async_cx = cx.to_async();
 999        let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
1000
1001        executor.run_until_parked();
1002
1003        assert!(result.is_ok(), "open_remote_project should succeed");
1004
1005        let windows = cx.update(|cx| cx.windows().len());
1006        assert_eq!(windows, 1, "Should have opened a window");
1007
1008        let workspace_handle = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
1009
1010        workspace_handle
1011            .update(cx, |workspace, _, cx| {
1012                let project = workspace.project().read(cx);
1013                assert!(project.is_remote(), "Project should be a remote project");
1014            })
1015            .unwrap();
1016    }
1017
1018    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1019        cx.update(|cx| {
1020            let state = AppState::test(cx);
1021            crate::init(cx);
1022            editor::init(cx);
1023            state
1024        })
1025    }
1026}