remote_connections.rs

  1use std::{
  2    path::{Path, PathBuf},
  3    sync::Arc,
  4};
  5
  6use anyhow::{Context as _, Result};
  7use askpass::EncryptedPassword;
  8use editor::Editor;
  9use extension_host::ExtensionStore;
 10use futures::{FutureExt as _, channel::oneshot, select};
 11use gpui::{AppContext, AsyncApp, PromptLevel, WindowHandle};
 12
 13use language::Point;
 14use project::trusted_worktrees;
 15use remote::{
 16    DockerConnectionOptions, Interactive, RemoteConnection, RemoteConnectionOptions,
 17    SshConnectionOptions,
 18};
 19pub use settings::SshConnection;
 20use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
 21use util::paths::PathWithPosition;
 22use workspace::{
 23    AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation, Workspace,
 24    find_existing_workspace,
 25};
 26
 27pub use remote_connection::{
 28    RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader,
 29    connect,
 30};
 31
 32#[derive(RegisterSetting)]
 33pub struct RemoteSettings {
 34    pub ssh_connections: ExtendingVec<SshConnection>,
 35    pub wsl_connections: ExtendingVec<WslConnection>,
 36    /// Whether to read ~/.ssh/config for ssh connection sources.
 37    pub read_ssh_config: bool,
 38}
 39
 40impl RemoteSettings {
 41    pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
 42        self.ssh_connections.clone().0.into_iter()
 43    }
 44
 45    pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
 46        self.wsl_connections.clone().0.into_iter()
 47    }
 48
 49    pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
 50        for conn in self.ssh_connections() {
 51            if conn.host == options.host.to_string()
 52                && conn.username == options.username
 53                && conn.port == options.port
 54            {
 55                options.nickname = conn.nickname;
 56                options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
 57                options.args = Some(conn.args);
 58                options.port_forwards = conn.port_forwards;
 59                break;
 60            }
 61        }
 62    }
 63
 64    pub fn connection_options_for(
 65        &self,
 66        host: String,
 67        port: Option<u16>,
 68        username: Option<String>,
 69    ) -> SshConnectionOptions {
 70        let mut options = SshConnectionOptions {
 71            host: host.into(),
 72            port,
 73            username,
 74            ..Default::default()
 75        };
 76        self.fill_connection_options_from_settings(&mut options);
 77        options
 78    }
 79}
 80
 81#[derive(Clone, PartialEq)]
 82pub enum Connection {
 83    Ssh(SshConnection),
 84    Wsl(WslConnection),
 85    DevContainer(DevContainerConnection),
 86}
 87
 88impl From<Connection> for RemoteConnectionOptions {
 89    fn from(val: Connection) -> Self {
 90        match val {
 91            Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
 92            Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
 93            Connection::DevContainer(conn) => {
 94                RemoteConnectionOptions::Docker(DockerConnectionOptions {
 95                    name: conn.name,
 96                    remote_user: conn.remote_user,
 97                    container_id: conn.container_id,
 98                    upload_binary_over_docker_exec: false,
 99                    use_podman: conn.use_podman,
100                })
101            }
102        }
103    }
104}
105
106impl From<SshConnection> for Connection {
107    fn from(val: SshConnection) -> Self {
108        Connection::Ssh(val)
109    }
110}
111
112impl From<WslConnection> for Connection {
113    fn from(val: WslConnection) -> Self {
114        Connection::Wsl(val)
115    }
116}
117
118impl Settings for RemoteSettings {
119    fn from_settings(content: &settings::SettingsContent) -> Self {
120        let remote = &content.remote;
121        Self {
122            ssh_connections: remote.ssh_connections.clone().unwrap_or_default().into(),
123            wsl_connections: remote.wsl_connections.clone().unwrap_or_default().into(),
124            read_ssh_config: remote.read_ssh_config.unwrap(),
125        }
126    }
127}
128
129pub async fn open_remote_project(
130    connection_options: RemoteConnectionOptions,
131    paths: Vec<PathBuf>,
132    app_state: Arc<AppState>,
133    open_options: workspace::OpenOptions,
134    cx: &mut AsyncApp,
135) -> Result<()> {
136    let created_new_window = open_options.replace_window.is_none();
137
138    let (existing, open_visible) = find_existing_workspace(
139        &paths,
140        &open_options,
141        &SerializedWorkspaceLocation::Remote(connection_options.clone()),
142        cx,
143    )
144    .await;
145
146    if let Some((existing_window, existing_workspace)) = existing {
147        let remote_connection = cx
148            .update(|cx| {
149                existing_workspace
150                    .read(cx)
151                    .project()
152                    .read(cx)
153                    .remote_client()
154                    .and_then(|client| client.read(cx).remote_connection())
155            })
156            .ok_or_else(|| anyhow::anyhow!("no remote connection for existing remote workspace"))?;
157
158        let (resolved_paths, paths_with_positions) =
159            determine_paths_with_positions(&remote_connection, paths).await;
160
161        let open_results = existing_window
162            .update(cx, |multi_workspace, window, cx| {
163                window.activate_window();
164                multi_workspace.activate(existing_workspace.clone(), cx);
165                existing_workspace.update(cx, |workspace, cx| {
166                    workspace.open_paths(
167                        resolved_paths,
168                        OpenOptions {
169                            visible: Some(open_visible),
170                            ..Default::default()
171                        },
172                        None,
173                        window,
174                        cx,
175                    )
176                })
177            })?
178            .await;
179
180        _ = existing_window.update(cx, |multi_workspace, _, cx| {
181            let workspace = multi_workspace.workspace().clone();
182            workspace.update(cx, |workspace, cx| {
183                for item in open_results.iter().flatten() {
184                    if let Err(e) = item {
185                        workspace.show_error(&e, cx);
186                    }
187                }
188            });
189        });
190
191        let items = open_results
192            .into_iter()
193            .map(|r| r.and_then(|r| r.ok()))
194            .collect::<Vec<_>>();
195        navigate_to_positions(&existing_window, items, &paths_with_positions, cx);
196
197        return Ok(());
198    }
199
200    let (window, initial_workspace) = if let Some(window) = open_options.replace_window {
201        let workspace = window.update(cx, |multi_workspace, _, _| {
202            multi_workspace.workspace().clone()
203        })?;
204        (window, workspace)
205    } else {
206        let workspace_position = cx
207            .update(|cx| {
208                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
209            })
210            .await
211            .context("fetching remote workspace position from db")?;
212
213        let mut options =
214            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
215        options.window_bounds = workspace_position.window_bounds;
216
217        let window = cx.open_window(options, |window, cx| {
218            let project = project::Project::local(
219                app_state.client.clone(),
220                app_state.node_runtime.clone(),
221                app_state.user_store.clone(),
222                app_state.languages.clone(),
223                app_state.fs.clone(),
224                None,
225                project::LocalProjectFlags {
226                    init_worktree_trust: false,
227                    ..Default::default()
228                },
229                cx,
230            );
231            let workspace = cx.new(|cx| {
232                let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
233                workspace.centered_layout = workspace_position.centered_layout;
234                workspace
235            });
236            cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
237        })?;
238        let workspace = window.update(cx, |multi_workspace, _, _cx| {
239            multi_workspace.workspace().clone()
240        })?;
241        (window, workspace)
242    };
243
244    loop {
245        let (cancel_tx, mut cancel_rx) = oneshot::channel();
246        let delegate = window.update(cx, {
247            let paths = paths.clone();
248            let connection_options = connection_options.clone();
249            let initial_workspace = initial_workspace.clone();
250            move |_multi_workspace: &mut MultiWorkspace, window, cx| {
251                window.activate_window();
252                initial_workspace.update(cx, |workspace, cx| {
253                    workspace.hide_modal(window, cx);
254                    workspace.toggle_modal(window, cx, |window, cx| {
255                        RemoteConnectionModal::new(&connection_options, paths, window, cx)
256                    });
257
258                    let ui = workspace
259                        .active_modal::<RemoteConnectionModal>(cx)?
260                        .read(cx)
261                        .prompt
262                        .clone();
263
264                    ui.update(cx, |ui, _cx| {
265                        ui.set_cancellation_tx(cancel_tx);
266                    });
267
268                    Some(Arc::new(RemoteClientDelegate::new(
269                        window.window_handle(),
270                        ui.downgrade(),
271                        if let RemoteConnectionOptions::Ssh(options) = &connection_options {
272                            options
273                                .password
274                                .as_deref()
275                                .and_then(|pw| EncryptedPassword::try_from(pw).ok())
276                        } else {
277                            None
278                        },
279                    )))
280                })
281            }
282        })?;
283
284        let Some(delegate) = delegate else { break };
285
286        let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
287        let connection = select! {
288            _ = cancel_rx => {
289                initial_workspace.update(cx, |workspace, cx| {
290                    if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
291                        ui.update(cx, |modal, cx| modal.finished(cx))
292                    }
293                });
294
295                break;
296            },
297            result = connection.fuse() => result,
298        };
299        let remote_connection = match connection {
300            Ok(connection) => connection,
301            Err(e) => {
302                initial_workspace.update(cx, |workspace, cx| {
303                    if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
304                        ui.update(cx, |modal, cx| modal.finished(cx))
305                    }
306                });
307                log::error!("Failed to open project: {e:#}");
308                let response = window
309                    .update(cx, |_, window, cx| {
310                        window.prompt(
311                            PromptLevel::Critical,
312                            match connection_options {
313                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
314                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
315                                RemoteConnectionOptions::Docker(_) => {
316                                    "Failed to connect to Dev Container"
317                                }
318                                #[cfg(any(test, feature = "test-support"))]
319                                RemoteConnectionOptions::Mock(_) => {
320                                    "Failed to connect to mock server"
321                                }
322                            },
323                            Some(&format!("{e:#}")),
324                            &["Retry", "Cancel"],
325                            cx,
326                        )
327                    })?
328                    .await;
329
330                if response == Ok(0) {
331                    continue;
332                }
333
334                if created_new_window {
335                    window
336                        .update(cx, |_, window, _| window.remove_window())
337                        .ok();
338                }
339                return Ok(());
340            }
341        };
342
343        let (paths, paths_with_positions) =
344            determine_paths_with_positions(&remote_connection, paths.clone()).await;
345
346        let opened_items = cx
347            .update(|cx| {
348                workspace::open_remote_project_with_new_connection(
349                    window,
350                    remote_connection,
351                    cancel_rx,
352                    delegate.clone(),
353                    app_state.clone(),
354                    paths.clone(),
355                    cx,
356                )
357            })
358            .await;
359
360        initial_workspace.update(cx, |workspace, cx| {
361            if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
362                ui.update(cx, |modal, cx| modal.finished(cx))
363            }
364        });
365
366        match opened_items {
367            Err(e) => {
368                log::error!("Failed to open project: {e:#}");
369                let response = window
370                    .update(cx, |_, window, cx| {
371                        window.prompt(
372                            PromptLevel::Critical,
373                            match connection_options {
374                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
375                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
376                                RemoteConnectionOptions::Docker(_) => {
377                                    "Failed to connect to Dev Container"
378                                }
379                                #[cfg(any(test, feature = "test-support"))]
380                                RemoteConnectionOptions::Mock(_) => {
381                                    "Failed to connect to mock server"
382                                }
383                            },
384                            Some(&format!("{e:#}")),
385                            &["Retry", "Cancel"],
386                            cx,
387                        )
388                    })?
389                    .await;
390                if response == Ok(0) {
391                    continue;
392                }
393
394                if created_new_window {
395                    window
396                        .update(cx, |_, window, _| window.remove_window())
397                        .ok();
398                }
399                initial_workspace.update(cx, |workspace, cx| {
400                    trusted_worktrees::track_worktree_trust(
401                        workspace.project().read(cx).worktree_store(),
402                        None,
403                        None,
404                        None,
405                        cx,
406                    );
407                });
408            }
409
410            Ok(items) => {
411                navigate_to_positions(&window, items, &paths_with_positions, cx);
412            }
413        }
414
415        break;
416    }
417
418    // Register the remote client with extensions. We use `multi_workspace.workspace()` here
419    // (not `initial_workspace`) because `open_remote_project_inner` activated the new remote
420    // workspace, so the active workspace is now the one with the remote project.
421    window
422        .update(cx, |multi_workspace: &mut MultiWorkspace, _, cx| {
423            let workspace = multi_workspace.workspace().clone();
424            workspace.update(cx, |workspace, cx| {
425                if let Some(client) = workspace.project().read(cx).remote_client() {
426                    if let Some(extension_store) = ExtensionStore::try_global(cx) {
427                        extension_store
428                            .update(cx, |store, cx| store.register_remote_client(client, cx));
429                    }
430                }
431            });
432        })
433        .ok();
434    Ok(())
435}
436
437pub fn navigate_to_positions(
438    window: &WindowHandle<MultiWorkspace>,
439    items: impl IntoIterator<Item = Option<Box<dyn workspace::item::ItemHandle>>>,
440    positions: &[PathWithPosition],
441    cx: &mut AsyncApp,
442) {
443    for (item, path) in items.into_iter().zip(positions) {
444        let Some(item) = item else {
445            continue;
446        };
447        let Some(row) = path.row else {
448            continue;
449        };
450        if let Some(active_editor) = item.downcast::<Editor>() {
451            window
452                .update(cx, |_, window, cx| {
453                    active_editor.update(cx, |editor, cx| {
454                        let row = row.saturating_sub(1);
455                        let col = path.column.unwrap_or(0).saturating_sub(1);
456                        editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx);
457                    });
458                })
459                .ok();
460        }
461    }
462}
463
464pub(crate) async fn determine_paths_with_positions(
465    remote_connection: &Arc<dyn RemoteConnection>,
466    mut paths: Vec<PathBuf>,
467) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
468    let mut paths_with_positions = Vec::<PathWithPosition>::new();
469    for path in &mut paths {
470        if let Some(path_str) = path.to_str() {
471            let path_with_position = PathWithPosition::parse_str(&path_str);
472            if path_with_position.row.is_some() {
473                if !path_exists(&remote_connection, &path).await {
474                    *path = path_with_position.path.clone();
475                    paths_with_positions.push(path_with_position);
476                    continue;
477                }
478            }
479        }
480        paths_with_positions.push(PathWithPosition::from_path(path.clone()))
481    }
482    (paths, paths_with_positions)
483}
484
485async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
486    let Ok(command) = connection.build_command(
487        Some("test".to_string()),
488        &["-e".to_owned(), path.to_string_lossy().to_string()],
489        &Default::default(),
490        None,
491        None,
492        Interactive::No,
493    ) else {
494        return false;
495    };
496    let Ok(mut child) = util::command::new_command(command.program)
497        .args(command.args)
498        .envs(command.env)
499        .spawn()
500    else {
501        return false;
502    };
503    child.status().await.is_ok_and(|status| status.success())
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use extension::ExtensionHostProxy;
510    use fs::FakeFs;
511    use gpui::{AppContext, TestAppContext};
512    use http_client::BlockedHttpClient;
513    use node_runtime::NodeRuntime;
514    use remote::RemoteClient;
515    use remote_server::{HeadlessAppState, HeadlessProject};
516    use serde_json::json;
517    use util::path;
518    use workspace::find_existing_workspace;
519
520    #[gpui::test]
521    async fn test_open_remote_project_with_mock_connection(
522        cx: &mut TestAppContext,
523        server_cx: &mut TestAppContext,
524    ) {
525        let app_state = init_test(cx);
526        let executor = cx.executor();
527
528        cx.update(|cx| {
529            release_channel::init(semver::Version::new(0, 0, 0), cx);
530        });
531        server_cx.update(|cx| {
532            release_channel::init(semver::Version::new(0, 0, 0), cx);
533        });
534
535        let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
536
537        let remote_fs = FakeFs::new(server_cx.executor());
538        remote_fs
539            .insert_tree(
540                path!("/project"),
541                json!({
542                    "src": {
543                        "main.rs": "fn main() {}",
544                    },
545                    "README.md": "# Test Project",
546                }),
547            )
548            .await;
549
550        server_cx.update(HeadlessProject::init);
551        let http_client = Arc::new(BlockedHttpClient);
552        let node_runtime = NodeRuntime::unavailable();
553        let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
554        let proxy = Arc::new(ExtensionHostProxy::new());
555
556        let _headless = server_cx.new(|cx| {
557            HeadlessProject::new(
558                HeadlessAppState {
559                    session: server_session,
560                    fs: remote_fs.clone(),
561                    http_client,
562                    node_runtime,
563                    languages,
564                    extension_host_proxy: proxy,
565                },
566                false,
567                cx,
568            )
569        });
570
571        drop(connect_guard);
572
573        let paths = vec![PathBuf::from(path!("/project"))];
574        let open_options = workspace::OpenOptions::default();
575
576        let mut async_cx = cx.to_async();
577        let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
578
579        executor.run_until_parked();
580
581        assert!(result.is_ok(), "open_remote_project should succeed");
582
583        let windows = cx.update(|cx| cx.windows().len());
584        assert_eq!(windows, 1, "Should have opened a window");
585
586        let multi_workspace_handle =
587            cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
588
589        multi_workspace_handle
590            .update(cx, |multi_workspace, _, cx| {
591                let workspace = multi_workspace.workspace().clone();
592                workspace.update(cx, |workspace, cx| {
593                    let project = workspace.project().read(cx);
594                    assert!(project.is_remote(), "Project should be a remote project");
595                });
596            })
597            .unwrap();
598    }
599
600    #[gpui::test]
601    async fn test_reuse_existing_remote_workspace_window(
602        cx: &mut TestAppContext,
603        server_cx: &mut TestAppContext,
604    ) {
605        let app_state = init_test(cx);
606        let executor = cx.executor();
607
608        cx.update(|cx| {
609            release_channel::init(semver::Version::new(0, 0, 0), cx);
610        });
611        server_cx.update(|cx| {
612            release_channel::init(semver::Version::new(0, 0, 0), cx);
613        });
614
615        let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
616
617        let remote_fs = FakeFs::new(server_cx.executor());
618        remote_fs
619            .insert_tree(
620                path!("/project"),
621                json!({
622                    "src": {
623                        "main.rs": "fn main() {}",
624                        "lib.rs": "pub fn hello() {}",
625                    },
626                    "README.md": "# Test Project",
627                }),
628            )
629            .await;
630
631        server_cx.update(HeadlessProject::init);
632        let http_client = Arc::new(BlockedHttpClient);
633        let node_runtime = NodeRuntime::unavailable();
634        let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
635        let proxy = Arc::new(ExtensionHostProxy::new());
636
637        let _headless = server_cx.new(|cx| {
638            HeadlessProject::new(
639                HeadlessAppState {
640                    session: server_session,
641                    fs: remote_fs.clone(),
642                    http_client,
643                    node_runtime,
644                    languages,
645                    extension_host_proxy: proxy,
646                },
647                false,
648                cx,
649            )
650        });
651
652        drop(connect_guard);
653
654        // First open: create a new window for the remote project.
655        let paths = vec![PathBuf::from(path!("/project"))];
656        let mut async_cx = cx.to_async();
657        open_remote_project(
658            opts.clone(),
659            paths,
660            app_state.clone(),
661            workspace::OpenOptions::default(),
662            &mut async_cx,
663        )
664        .await
665        .expect("first open_remote_project should succeed");
666
667        executor.run_until_parked();
668
669        assert_eq!(
670            cx.update(|cx| cx.windows().len()),
671            1,
672            "First open should create exactly one window"
673        );
674
675        let first_window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
676
677        // Verify find_existing_workspace discovers the remote workspace.
678        let search_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
679        let (found, _open_visible) = find_existing_workspace(
680            &search_paths,
681            &workspace::OpenOptions::default(),
682            &SerializedWorkspaceLocation::Remote(opts.clone()),
683            &mut async_cx,
684        )
685        .await;
686
687        assert!(
688            found.is_some(),
689            "find_existing_workspace should locate the existing remote workspace"
690        );
691        let (found_window, _found_workspace) = found.unwrap();
692        assert_eq!(
693            found_window, first_window,
694            "find_existing_workspace should return the same window"
695        );
696
697        // Second open with the same connection options should reuse the window.
698        let second_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
699        open_remote_project(
700            opts.clone(),
701            second_paths,
702            app_state.clone(),
703            workspace::OpenOptions::default(),
704            &mut async_cx,
705        )
706        .await
707        .expect("second open_remote_project should succeed via reuse");
708
709        executor.run_until_parked();
710
711        assert_eq!(
712            cx.update(|cx| cx.windows().len()),
713            1,
714            "Second open should reuse the existing window, not create a new one"
715        );
716
717        let still_first_window =
718            cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
719        assert_eq!(
720            still_first_window, first_window,
721            "The window handle should be the same after reuse"
722        );
723    }
724
725    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
726        cx.update(|cx| {
727            let state = AppState::test(cx);
728            crate::init(cx);
729            editor::init(cx);
730            state
731        })
732    }
733}