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