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};
 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::{AppState, MultiWorkspace, Workspace};
 23
 24pub use remote_connection::{
 25    RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader,
 26    connect,
 27};
 28
 29#[derive(RegisterSetting)]
 30pub struct RemoteSettings {
 31    pub ssh_connections: ExtendingVec<SshConnection>,
 32    pub wsl_connections: ExtendingVec<WslConnection>,
 33    /// Whether to read ~/.ssh/config for ssh connection sources.
 34    pub read_ssh_config: bool,
 35}
 36
 37impl RemoteSettings {
 38    pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
 39        self.ssh_connections.clone().0.into_iter()
 40    }
 41
 42    pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
 43        self.wsl_connections.clone().0.into_iter()
 44    }
 45
 46    pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
 47        for conn in self.ssh_connections() {
 48            if conn.host == options.host.to_string()
 49                && conn.username == options.username
 50                && conn.port == options.port
 51            {
 52                options.nickname = conn.nickname;
 53                options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
 54                options.args = Some(conn.args);
 55                options.port_forwards = conn.port_forwards;
 56                break;
 57            }
 58        }
 59    }
 60
 61    pub fn connection_options_for(
 62        &self,
 63        host: String,
 64        port: Option<u16>,
 65        username: Option<String>,
 66    ) -> SshConnectionOptions {
 67        let mut options = SshConnectionOptions {
 68            host: host.into(),
 69            port,
 70            username,
 71            ..Default::default()
 72        };
 73        self.fill_connection_options_from_settings(&mut options);
 74        options
 75    }
 76}
 77
 78#[derive(Clone, PartialEq)]
 79pub enum Connection {
 80    Ssh(SshConnection),
 81    Wsl(WslConnection),
 82    DevContainer(DevContainerConnection),
 83}
 84
 85impl From<Connection> for RemoteConnectionOptions {
 86    fn from(val: Connection) -> Self {
 87        match val {
 88            Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
 89            Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
 90            Connection::DevContainer(conn) => {
 91                RemoteConnectionOptions::Docker(DockerConnectionOptions {
 92                    name: conn.name,
 93                    remote_user: conn.remote_user,
 94                    container_id: conn.container_id,
 95                    upload_binary_over_docker_exec: false,
 96                    use_podman: conn.use_podman,
 97                })
 98            }
 99        }
100    }
101}
102
103impl From<SshConnection> for Connection {
104    fn from(val: SshConnection) -> Self {
105        Connection::Ssh(val)
106    }
107}
108
109impl From<WslConnection> for Connection {
110    fn from(val: WslConnection) -> Self {
111        Connection::Wsl(val)
112    }
113}
114
115impl Settings for RemoteSettings {
116    fn from_settings(content: &settings::SettingsContent) -> Self {
117        let remote = &content.remote;
118        Self {
119            ssh_connections: remote.ssh_connections.clone().unwrap_or_default().into(),
120            wsl_connections: remote.wsl_connections.clone().unwrap_or_default().into(),
121            read_ssh_config: remote.read_ssh_config.unwrap(),
122        }
123    }
124}
125
126pub async fn open_remote_project(
127    connection_options: RemoteConnectionOptions,
128    paths: Vec<PathBuf>,
129    app_state: Arc<AppState>,
130    open_options: workspace::OpenOptions,
131    cx: &mut AsyncApp,
132) -> Result<()> {
133    let created_new_window = open_options.replace_window.is_none();
134    let (window, initial_workspace) = if let Some(window) = open_options.replace_window {
135        let workspace = window.update(cx, |multi_workspace, _, _| {
136            multi_workspace.workspace().clone()
137        })?;
138        (window, workspace)
139    } else {
140        let workspace_position = cx
141            .update(|cx| {
142                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
143            })
144            .await
145            .context("fetching remote workspace position from db")?;
146
147        let mut options =
148            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
149        options.window_bounds = workspace_position.window_bounds;
150
151        let window = cx.open_window(options, |window, cx| {
152            let project = project::Project::local(
153                app_state.client.clone(),
154                app_state.node_runtime.clone(),
155                app_state.user_store.clone(),
156                app_state.languages.clone(),
157                app_state.fs.clone(),
158                None,
159                project::LocalProjectFlags {
160                    init_worktree_trust: false,
161                    ..Default::default()
162                },
163                cx,
164            );
165            let workspace = cx.new(|cx| {
166                let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
167                workspace.centered_layout = workspace_position.centered_layout;
168                workspace
169            });
170            cx.new(|cx| MultiWorkspace::new(workspace, cx))
171        })?;
172        let workspace = window.update(cx, |multi_workspace, _, _cx| {
173            multi_workspace.workspace().clone()
174        })?;
175        (window, workspace)
176    };
177
178    loop {
179        let (cancel_tx, mut cancel_rx) = oneshot::channel();
180        let delegate = window.update(cx, {
181            let paths = paths.clone();
182            let connection_options = connection_options.clone();
183            let initial_workspace = initial_workspace.clone();
184            move |_multi_workspace: &mut MultiWorkspace, window, cx| {
185                window.activate_window();
186                initial_workspace.update(cx, |workspace, cx| {
187                    workspace.hide_modal(window, cx);
188                    workspace.toggle_modal(window, cx, |window, cx| {
189                        RemoteConnectionModal::new(&connection_options, paths, window, cx)
190                    });
191
192                    let ui = workspace
193                        .active_modal::<RemoteConnectionModal>(cx)?
194                        .read(cx)
195                        .prompt
196                        .clone();
197
198                    ui.update(cx, |ui, _cx| {
199                        ui.set_cancellation_tx(cancel_tx);
200                    });
201
202                    Some(Arc::new(RemoteClientDelegate::new(
203                        window.window_handle(),
204                        ui.downgrade(),
205                        if let RemoteConnectionOptions::Ssh(options) = &connection_options {
206                            options
207                                .password
208                                .as_deref()
209                                .and_then(|pw| EncryptedPassword::try_from(pw).ok())
210                        } else {
211                            None
212                        },
213                    )))
214                })
215            }
216        })?;
217
218        let Some(delegate) = delegate else { break };
219
220        let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
221        let connection = select! {
222            _ = cancel_rx => {
223                initial_workspace.update(cx, |workspace, cx| {
224                    if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
225                        ui.update(cx, |modal, cx| modal.finished(cx))
226                    }
227                });
228
229                break;
230            },
231            result = connection.fuse() => result,
232        };
233        let remote_connection = match connection {
234            Ok(connection) => connection,
235            Err(e) => {
236                initial_workspace.update(cx, |workspace, cx| {
237                    if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
238                        ui.update(cx, |modal, cx| modal.finished(cx))
239                    }
240                });
241                log::error!("Failed to open project: {e:#}");
242                let response = window
243                    .update(cx, |_, window, cx| {
244                        window.prompt(
245                            PromptLevel::Critical,
246                            match connection_options {
247                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
248                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
249                                RemoteConnectionOptions::Docker(_) => {
250                                    "Failed to connect to Dev Container"
251                                }
252                                #[cfg(any(test, feature = "test-support"))]
253                                RemoteConnectionOptions::Mock(_) => {
254                                    "Failed to connect to mock server"
255                                }
256                            },
257                            Some(&format!("{e:#}")),
258                            &["Retry", "Cancel"],
259                            cx,
260                        )
261                    })?
262                    .await;
263
264                if response == Ok(0) {
265                    continue;
266                }
267
268                if created_new_window {
269                    window
270                        .update(cx, |_, window, _| window.remove_window())
271                        .ok();
272                }
273                return Ok(());
274            }
275        };
276
277        let (paths, paths_with_positions) =
278            determine_paths_with_positions(&remote_connection, paths.clone()).await;
279
280        let opened_items = cx
281            .update(|cx| {
282                workspace::open_remote_project_with_new_connection(
283                    window,
284                    remote_connection,
285                    cancel_rx,
286                    delegate.clone(),
287                    app_state.clone(),
288                    paths.clone(),
289                    cx,
290                )
291            })
292            .await;
293
294        initial_workspace.update(cx, |workspace, cx| {
295            if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
296                ui.update(cx, |modal, cx| modal.finished(cx))
297            }
298        });
299
300        match opened_items {
301            Err(e) => {
302                log::error!("Failed to open project: {e:#}");
303                let response = window
304                    .update(cx, |_, window, cx| {
305                        window.prompt(
306                            PromptLevel::Critical,
307                            match connection_options {
308                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
309                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
310                                RemoteConnectionOptions::Docker(_) => {
311                                    "Failed to connect to Dev Container"
312                                }
313                                #[cfg(any(test, feature = "test-support"))]
314                                RemoteConnectionOptions::Mock(_) => {
315                                    "Failed to connect to mock server"
316                                }
317                            },
318                            Some(&format!("{e:#}")),
319                            &["Retry", "Cancel"],
320                            cx,
321                        )
322                    })?
323                    .await;
324                if response == Ok(0) {
325                    continue;
326                }
327
328                if created_new_window {
329                    window
330                        .update(cx, |_, window, _| window.remove_window())
331                        .ok();
332                }
333                initial_workspace.update(cx, |workspace, cx| {
334                    trusted_worktrees::track_worktree_trust(
335                        workspace.project().read(cx).worktree_store(),
336                        None,
337                        None,
338                        None,
339                        cx,
340                    );
341                });
342            }
343
344            Ok(items) => {
345                for (item, path) in items.into_iter().zip(paths_with_positions) {
346                    let Some(item) = item else {
347                        continue;
348                    };
349                    let Some(row) = path.row else {
350                        continue;
351                    };
352                    if let Some(active_editor) = item.downcast::<Editor>() {
353                        window
354                            .update(cx, |_, window, cx| {
355                                active_editor.update(cx, |editor, cx| {
356                                    let row = row.saturating_sub(1);
357                                    let col = path.column.unwrap_or(0).saturating_sub(1);
358                                    editor.go_to_singleton_buffer_point(
359                                        Point::new(row, col),
360                                        window,
361                                        cx,
362                                    );
363                                });
364                            })
365                            .ok();
366                    }
367                }
368            }
369        }
370
371        break;
372    }
373
374    // Register the remote client with extensions. We use `multi_workspace.workspace()` here
375    // (not `initial_workspace`) because `open_remote_project_inner` activated the new remote
376    // workspace, so the active workspace is now the one with the remote project.
377    window
378        .update(cx, |multi_workspace: &mut MultiWorkspace, _, cx| {
379            let workspace = multi_workspace.workspace().clone();
380            workspace.update(cx, |workspace, cx| {
381                if let Some(client) = workspace.project().read(cx).remote_client() {
382                    if let Some(extension_store) = ExtensionStore::try_global(cx) {
383                        extension_store
384                            .update(cx, |store, cx| store.register_remote_client(client, cx));
385                    }
386                }
387            });
388        })
389        .ok();
390    Ok(())
391}
392
393pub(crate) async fn determine_paths_with_positions(
394    remote_connection: &Arc<dyn RemoteConnection>,
395    mut paths: Vec<PathBuf>,
396) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
397    let mut paths_with_positions = Vec::<PathWithPosition>::new();
398    for path in &mut paths {
399        if let Some(path_str) = path.to_str() {
400            let path_with_position = PathWithPosition::parse_str(&path_str);
401            if path_with_position.row.is_some() {
402                if !path_exists(&remote_connection, &path).await {
403                    *path = path_with_position.path.clone();
404                    paths_with_positions.push(path_with_position);
405                    continue;
406                }
407            }
408        }
409        paths_with_positions.push(PathWithPosition::from_path(path.clone()))
410    }
411    (paths, paths_with_positions)
412}
413
414async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
415    let Ok(command) = connection.build_command(
416        Some("test".to_string()),
417        &["-e".to_owned(), path.to_string_lossy().to_string()],
418        &Default::default(),
419        None,
420        None,
421        Interactive::No,
422    ) else {
423        return false;
424    };
425    let Ok(mut child) = util::command::new_command(command.program)
426        .args(command.args)
427        .envs(command.env)
428        .spawn()
429    else {
430        return false;
431    };
432    child.status().await.is_ok_and(|status| status.success())
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use extension::ExtensionHostProxy;
439    use fs::FakeFs;
440    use gpui::{AppContext, TestAppContext};
441    use http_client::BlockedHttpClient;
442    use node_runtime::NodeRuntime;
443    use remote::RemoteClient;
444    use remote_server::{HeadlessAppState, HeadlessProject};
445    use serde_json::json;
446    use util::path;
447
448    #[gpui::test]
449    async fn test_open_remote_project_with_mock_connection(
450        cx: &mut TestAppContext,
451        server_cx: &mut TestAppContext,
452    ) {
453        let app_state = init_test(cx);
454        let executor = cx.executor();
455
456        cx.update(|cx| {
457            release_channel::init(semver::Version::new(0, 0, 0), cx);
458        });
459        server_cx.update(|cx| {
460            release_channel::init(semver::Version::new(0, 0, 0), cx);
461        });
462
463        let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
464
465        let remote_fs = FakeFs::new(server_cx.executor());
466        remote_fs
467            .insert_tree(
468                path!("/project"),
469                json!({
470                    "src": {
471                        "main.rs": "fn main() {}",
472                    },
473                    "README.md": "# Test Project",
474                }),
475            )
476            .await;
477
478        server_cx.update(HeadlessProject::init);
479        let http_client = Arc::new(BlockedHttpClient);
480        let node_runtime = NodeRuntime::unavailable();
481        let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
482        let proxy = Arc::new(ExtensionHostProxy::new());
483
484        let _headless = server_cx.new(|cx| {
485            HeadlessProject::new(
486                HeadlessAppState {
487                    session: server_session,
488                    fs: remote_fs.clone(),
489                    http_client,
490                    node_runtime,
491                    languages,
492                    extension_host_proxy: proxy,
493                },
494                false,
495                cx,
496            )
497        });
498
499        drop(connect_guard);
500
501        let paths = vec![PathBuf::from(path!("/project"))];
502        let open_options = workspace::OpenOptions::default();
503
504        let mut async_cx = cx.to_async();
505        let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
506
507        executor.run_until_parked();
508
509        assert!(result.is_ok(), "open_remote_project should succeed");
510
511        let windows = cx.update(|cx| cx.windows().len());
512        assert_eq!(windows, 1, "Should have opened a window");
513
514        let multi_workspace_handle =
515            cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
516
517        multi_workspace_handle
518            .update(cx, |multi_workspace, _, cx| {
519                let workspace = multi_workspace.workspace().clone();
520                workspace.update(cx, |workspace, cx| {
521                    let project = workspace.project().read(cx);
522                    assert!(project.is_remote(), "Project should be a remote project");
523                });
524            })
525            .unwrap();
526    }
527
528    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
529        cx.update(|cx| {
530            let state = AppState::test(cx);
531            crate::init(cx);
532            editor::init(cx);
533            state
534        })
535    }
536}