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 startup_time: std::time::Instant::now(),
566 },
567 false,
568 cx,
569 )
570 });
571
572 drop(connect_guard);
573
574 let paths = vec![PathBuf::from(path!("/project"))];
575 let open_options = workspace::OpenOptions::default();
576
577 let mut async_cx = cx.to_async();
578 let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
579
580 executor.run_until_parked();
581
582 assert!(result.is_ok(), "open_remote_project should succeed");
583
584 let windows = cx.update(|cx| cx.windows().len());
585 assert_eq!(windows, 1, "Should have opened a window");
586
587 let multi_workspace_handle =
588 cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
589
590 multi_workspace_handle
591 .update(cx, |multi_workspace, _, cx| {
592 let workspace = multi_workspace.workspace().clone();
593 workspace.update(cx, |workspace, cx| {
594 let project = workspace.project().read(cx);
595 assert!(project.is_remote(), "Project should be a remote project");
596 });
597 })
598 .unwrap();
599 }
600
601 #[gpui::test]
602 async fn test_reuse_existing_remote_workspace_window(
603 cx: &mut TestAppContext,
604 server_cx: &mut TestAppContext,
605 ) {
606 let app_state = init_test(cx);
607 let executor = cx.executor();
608
609 cx.update(|cx| {
610 release_channel::init(semver::Version::new(0, 0, 0), cx);
611 });
612 server_cx.update(|cx| {
613 release_channel::init(semver::Version::new(0, 0, 0), cx);
614 });
615
616 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
617
618 let remote_fs = FakeFs::new(server_cx.executor());
619 remote_fs
620 .insert_tree(
621 path!("/project"),
622 json!({
623 "src": {
624 "main.rs": "fn main() {}",
625 "lib.rs": "pub fn hello() {}",
626 },
627 "README.md": "# Test Project",
628 }),
629 )
630 .await;
631
632 server_cx.update(HeadlessProject::init);
633 let http_client = Arc::new(BlockedHttpClient);
634 let node_runtime = NodeRuntime::unavailable();
635 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
636 let proxy = Arc::new(ExtensionHostProxy::new());
637
638 let _headless = server_cx.new(|cx| {
639 HeadlessProject::new(
640 HeadlessAppState {
641 session: server_session,
642 fs: remote_fs.clone(),
643 http_client,
644 node_runtime,
645 languages,
646 extension_host_proxy: proxy,
647 startup_time: std::time::Instant::now(),
648 },
649 false,
650 cx,
651 )
652 });
653
654 drop(connect_guard);
655
656 // First open: create a new window for the remote project.
657 let paths = vec![PathBuf::from(path!("/project"))];
658 let mut async_cx = cx.to_async();
659 open_remote_project(
660 opts.clone(),
661 paths,
662 app_state.clone(),
663 workspace::OpenOptions::default(),
664 &mut async_cx,
665 )
666 .await
667 .expect("first open_remote_project should succeed");
668
669 executor.run_until_parked();
670
671 assert_eq!(
672 cx.update(|cx| cx.windows().len()),
673 1,
674 "First open should create exactly one window"
675 );
676
677 let first_window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
678
679 // Verify find_existing_workspace discovers the remote workspace.
680 let search_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
681 let (found, _open_visible) = find_existing_workspace(
682 &search_paths,
683 &workspace::OpenOptions::default(),
684 &SerializedWorkspaceLocation::Remote(opts.clone()),
685 &mut async_cx,
686 )
687 .await;
688
689 assert!(
690 found.is_some(),
691 "find_existing_workspace should locate the existing remote workspace"
692 );
693 let (found_window, _found_workspace) = found.unwrap();
694 assert_eq!(
695 found_window, first_window,
696 "find_existing_workspace should return the same window"
697 );
698
699 // Second open with the same connection options should reuse the window.
700 let second_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
701 open_remote_project(
702 opts.clone(),
703 second_paths,
704 app_state.clone(),
705 workspace::OpenOptions::default(),
706 &mut async_cx,
707 )
708 .await
709 .expect("second open_remote_project should succeed via reuse");
710
711 executor.run_until_parked();
712
713 assert_eq!(
714 cx.update(|cx| cx.windows().len()),
715 1,
716 "Second open should reuse the existing window, not create a new one"
717 );
718
719 let still_first_window =
720 cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
721 assert_eq!(
722 still_first_window, first_window,
723 "The window handle should be the same after reuse"
724 );
725 }
726
727 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
728 cx.update(|cx| {
729 let state = AppState::test(cx);
730 crate::init(cx);
731 editor::init(cx);
732 state
733 })
734 }
735}