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, 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 = if let Some(window) = open_options.replace_window {
135        window
136    } else {
137        let workspace_position = cx
138            .update(|cx| {
139                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
140            })
141            .await
142            .context("fetching remote workspace position from db")?;
143
144        let mut options =
145            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
146        options.window_bounds = workspace_position.window_bounds;
147
148        cx.open_window(options, |window, cx| {
149            let project = project::Project::local(
150                app_state.client.clone(),
151                app_state.node_runtime.clone(),
152                app_state.user_store.clone(),
153                app_state.languages.clone(),
154                app_state.fs.clone(),
155                None,
156                project::LocalProjectFlags {
157                    init_worktree_trust: false,
158                    ..Default::default()
159                },
160                cx,
161            );
162            cx.new(|cx| {
163                let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
164                workspace.centered_layout = workspace_position.centered_layout;
165                workspace
166            })
167        })?
168    };
169
170    loop {
171        let (cancel_tx, mut cancel_rx) = oneshot::channel();
172        let delegate = window.update(cx, {
173            let paths = paths.clone();
174            let connection_options = connection_options.clone();
175            move |workspace, window, cx| {
176                window.activate_window();
177                workspace.hide_modal(window, cx);
178                workspace.toggle_modal(window, cx, |window, cx| {
179                    RemoteConnectionModal::new(&connection_options, paths, window, cx)
180                });
181
182                let ui = workspace
183                    .active_modal::<RemoteConnectionModal>(cx)?
184                    .read(cx)
185                    .prompt
186                    .clone();
187
188                ui.update(cx, |ui, _cx| {
189                    ui.set_cancellation_tx(cancel_tx);
190                });
191
192                Some(Arc::new(RemoteClientDelegate::new(
193                    window.window_handle(),
194                    ui.downgrade(),
195                    if let RemoteConnectionOptions::Ssh(options) = &connection_options {
196                        options
197                            .password
198                            .as_deref()
199                            .and_then(|pw| EncryptedPassword::try_from(pw).ok())
200                    } else {
201                        None
202                    },
203                )))
204            }
205        })?;
206
207        let Some(delegate) = delegate else { break };
208
209        let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
210        let connection = select! {
211            _ = cancel_rx => {
212                window
213                    .update(cx, |workspace, _, cx| {
214                        if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
215                            ui.update(cx, |modal, cx| modal.finished(cx))
216                        }
217                    })
218                    .ok();
219
220                break;
221            },
222            result = connection.fuse() => result,
223        };
224        let remote_connection = match connection {
225            Ok(connection) => connection,
226            Err(e) => {
227                window
228                    .update(cx, |workspace, _, cx| {
229                        if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
230                            ui.update(cx, |modal, cx| modal.finished(cx))
231                        }
232                    })
233                    .ok();
234                log::error!("Failed to open project: {e:#}");
235                let response = window
236                    .update(cx, |_, window, cx| {
237                        window.prompt(
238                            PromptLevel::Critical,
239                            match connection_options {
240                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
241                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
242                                RemoteConnectionOptions::Docker(_) => {
243                                    "Failed to connect to Dev Container"
244                                }
245                                #[cfg(any(test, feature = "test-support"))]
246                                RemoteConnectionOptions::Mock(_) => {
247                                    "Failed to connect to mock server"
248                                }
249                            },
250                            Some(&format!("{e:#}")),
251                            &["Retry", "Cancel"],
252                            cx,
253                        )
254                    })?
255                    .await;
256
257                if response == Ok(0) {
258                    continue;
259                }
260
261                if created_new_window {
262                    window
263                        .update(cx, |_, window, _| window.remove_window())
264                        .ok();
265                }
266                return Ok(());
267            }
268        };
269
270        let (paths, paths_with_positions) =
271            determine_paths_with_positions(&remote_connection, paths.clone()).await;
272
273        let opened_items = cx
274            .update(|cx| {
275                workspace::open_remote_project_with_new_connection(
276                    window,
277                    remote_connection,
278                    cancel_rx,
279                    delegate.clone(),
280                    app_state.clone(),
281                    paths.clone(),
282                    cx,
283                )
284            })
285            .await;
286
287        window
288            .update(cx, |workspace, _, cx| {
289                if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
290                    ui.update(cx, |modal, cx| modal.finished(cx))
291                }
292            })
293            .ok();
294
295        match opened_items {
296            Err(e) => {
297                log::error!("Failed to open project: {e:#}");
298                let response = window
299                    .update(cx, |_, window, cx| {
300                        window.prompt(
301                            PromptLevel::Critical,
302                            match connection_options {
303                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
304                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
305                                RemoteConnectionOptions::Docker(_) => {
306                                    "Failed to connect to Dev Container"
307                                }
308                                #[cfg(any(test, feature = "test-support"))]
309                                RemoteConnectionOptions::Mock(_) => {
310                                    "Failed to connect to mock server"
311                                }
312                            },
313                            Some(&format!("{e:#}")),
314                            &["Retry", "Cancel"],
315                            cx,
316                        )
317                    })?
318                    .await;
319                if response == Ok(0) {
320                    continue;
321                }
322
323                window
324                    .update(cx, |workspace, window, cx| {
325                        if created_new_window {
326                            window.remove_window();
327                        }
328                        trusted_worktrees::track_worktree_trust(
329                            workspace.project().read(cx).worktree_store(),
330                            None,
331                            None,
332                            None,
333                            cx,
334                        );
335                    })
336                    .ok();
337            }
338
339            Ok(items) => {
340                for (item, path) in items.into_iter().zip(paths_with_positions) {
341                    let Some(item) = item else {
342                        continue;
343                    };
344                    let Some(row) = path.row else {
345                        continue;
346                    };
347                    if let Some(active_editor) = item.downcast::<Editor>() {
348                        window
349                            .update(cx, |_, window, cx| {
350                                active_editor.update(cx, |editor, cx| {
351                                    let row = row.saturating_sub(1);
352                                    let col = path.column.unwrap_or(0).saturating_sub(1);
353                                    editor.go_to_singleton_buffer_point(
354                                        Point::new(row, col),
355                                        window,
356                                        cx,
357                                    );
358                                });
359                            })
360                            .ok();
361                    }
362                }
363            }
364        }
365
366        break;
367    }
368
369    window
370        .update(cx, |workspace, _, cx| {
371            if let Some(client) = workspace.project().read(cx).remote_client() {
372                if let Some(extension_store) = ExtensionStore::try_global(cx) {
373                    extension_store
374                        .update(cx, |store, cx| store.register_remote_client(client, cx));
375                }
376            }
377        })
378        .ok();
379    Ok(())
380}
381
382pub(crate) async fn determine_paths_with_positions(
383    remote_connection: &Arc<dyn RemoteConnection>,
384    mut paths: Vec<PathBuf>,
385) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
386    let mut paths_with_positions = Vec::<PathWithPosition>::new();
387    for path in &mut paths {
388        if let Some(path_str) = path.to_str() {
389            let path_with_position = PathWithPosition::parse_str(&path_str);
390            if path_with_position.row.is_some() {
391                if !path_exists(&remote_connection, &path).await {
392                    *path = path_with_position.path.clone();
393                    paths_with_positions.push(path_with_position);
394                    continue;
395                }
396            }
397        }
398        paths_with_positions.push(PathWithPosition::from_path(path.clone()))
399    }
400    (paths, paths_with_positions)
401}
402
403async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
404    let Ok(command) = connection.build_command(
405        Some("test".to_string()),
406        &["-e".to_owned(), path.to_string_lossy().to_string()],
407        &Default::default(),
408        None,
409        None,
410        Interactive::No,
411    ) else {
412        return false;
413    };
414    let Ok(mut child) = util::command::new_smol_command(command.program)
415        .args(command.args)
416        .envs(command.env)
417        .spawn()
418    else {
419        return false;
420    };
421    child.status().await.is_ok_and(|status| status.success())
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use extension::ExtensionHostProxy;
428    use fs::FakeFs;
429    use gpui::{AppContext, TestAppContext};
430    use http_client::BlockedHttpClient;
431    use node_runtime::NodeRuntime;
432    use remote::RemoteClient;
433    use remote_server::{HeadlessAppState, HeadlessProject};
434    use serde_json::json;
435    use util::path;
436
437    #[gpui::test]
438    async fn test_open_remote_project_with_mock_connection(
439        cx: &mut TestAppContext,
440        server_cx: &mut TestAppContext,
441    ) {
442        let app_state = init_test(cx);
443        let executor = cx.executor();
444
445        cx.update(|cx| {
446            release_channel::init(semver::Version::new(0, 0, 0), cx);
447        });
448        server_cx.update(|cx| {
449            release_channel::init(semver::Version::new(0, 0, 0), cx);
450        });
451
452        let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
453
454        let remote_fs = FakeFs::new(server_cx.executor());
455        remote_fs
456            .insert_tree(
457                path!("/project"),
458                json!({
459                    "src": {
460                        "main.rs": "fn main() {}",
461                    },
462                    "README.md": "# Test Project",
463                }),
464            )
465            .await;
466
467        server_cx.update(HeadlessProject::init);
468        let http_client = Arc::new(BlockedHttpClient);
469        let node_runtime = NodeRuntime::unavailable();
470        let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
471        let proxy = Arc::new(ExtensionHostProxy::new());
472
473        let _headless = server_cx.new(|cx| {
474            HeadlessProject::new(
475                HeadlessAppState {
476                    session: server_session,
477                    fs: remote_fs.clone(),
478                    http_client,
479                    node_runtime,
480                    languages,
481                    extension_host_proxy: proxy,
482                },
483                false,
484                cx,
485            )
486        });
487
488        drop(connect_guard);
489
490        let paths = vec![PathBuf::from(path!("/project"))];
491        let open_options = workspace::OpenOptions::default();
492
493        let mut async_cx = cx.to_async();
494        let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
495
496        executor.run_until_parked();
497
498        assert!(result.is_ok(), "open_remote_project should succeed");
499
500        let windows = cx.update(|cx| cx.windows().len());
501        assert_eq!(windows, 1, "Should have opened a window");
502
503        let workspace_handle = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
504
505        workspace_handle
506            .update(cx, |workspace, _, cx| {
507                let project = workspace.project().read(cx);
508                assert!(project.is_remote(), "Project should be a remote project");
509            })
510            .unwrap();
511    }
512
513    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
514        cx.update(|cx| {
515            let state = AppState::test(cx);
516            crate::init(cx);
517            editor::init(cx);
518            state
519        })
520    }
521}