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}