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