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 project::trusted_worktrees;
14use remote::{
15 DockerConnectionOptions, Interactive, RemoteConnection, RemoteConnectionOptions,
16 SshConnectionOptions,
17};
18pub use settings::SshConnection;
19use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
20use util::paths::PathWithPosition;
21use workspace::{
22 AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation, Workspace,
23 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 remote_env: conn.remote_env,
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.requesting_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.update(|cx| {
148 existing_workspace
149 .read(cx)
150 .project()
151 .read(cx)
152 .remote_client()
153 .and_then(|client| client.read(cx).remote_connection())
154 });
155
156 if let Some(remote_connection) = remote_connection {
157 let (resolved_paths, paths_with_positions) =
158 determine_paths_with_positions(&remote_connection, paths).await;
159
160 let open_results = existing_window
161 .update(cx, |multi_workspace, window, cx| {
162 window.activate_window();
163 multi_workspace.activate(existing_workspace.clone(), window, cx);
164 existing_workspace.update(cx, |workspace, cx| {
165 workspace.open_paths(
166 resolved_paths,
167 OpenOptions {
168 visible: Some(open_visible),
169 ..Default::default()
170 },
171 None,
172 window,
173 cx,
174 )
175 })
176 })?
177 .await;
178
179 _ = existing_window.update(cx, |multi_workspace, _, cx| {
180 let workspace = multi_workspace.workspace().clone();
181 workspace.update(cx, |workspace, cx| {
182 for item in open_results.iter().flatten() {
183 if let Err(e) = item {
184 workspace.show_error(&e, cx);
185 }
186 }
187 });
188 });
189
190 let items = open_results
191 .into_iter()
192 .map(|r| r.and_then(|r| r.ok()))
193 .collect::<Vec<_>>();
194 navigate_to_positions(&existing_window, items, &paths_with_positions, cx);
195
196 return Ok(());
197 }
198 // If the remote connection is dead (e.g. server not running after failed reconnect),
199 // fall through to establish a fresh connection instead of showing an error.
200 log::info!(
201 "existing remote workspace found but connection is dead, starting fresh connection"
202 );
203 }
204
205 let (window, initial_workspace) = if let Some(window) = open_options.requesting_window {
206 let workspace = window.update(cx, |multi_workspace, _, _| {
207 multi_workspace.workspace().clone()
208 })?;
209 (window, workspace)
210 } else {
211 let workspace_position = cx
212 .update(|cx| {
213 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
214 })
215 .await
216 .context("fetching remote workspace position from db")?;
217
218 let mut options =
219 cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
220 options.window_bounds = workspace_position.window_bounds;
221
222 let window = cx.open_window(options, |window, cx| {
223 let project = project::Project::local(
224 app_state.client.clone(),
225 app_state.node_runtime.clone(),
226 app_state.user_store.clone(),
227 app_state.languages.clone(),
228 app_state.fs.clone(),
229 None,
230 project::LocalProjectFlags {
231 init_worktree_trust: false,
232 ..Default::default()
233 },
234 cx,
235 );
236 let workspace = cx.new(|cx| {
237 let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
238 workspace.centered_layout = workspace_position.centered_layout;
239 workspace
240 });
241 cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
242 })?;
243 let workspace = window.update(cx, |multi_workspace, _, _cx| {
244 multi_workspace.workspace().clone()
245 })?;
246 (window, workspace)
247 };
248
249 loop {
250 let (cancel_tx, mut cancel_rx) = oneshot::channel();
251 let delegate = window.update(cx, {
252 let paths = paths.clone();
253 let connection_options = connection_options.clone();
254 let initial_workspace = initial_workspace.clone();
255 move |_multi_workspace: &mut MultiWorkspace, window, cx| {
256 window.activate_window();
257 initial_workspace.update(cx, |workspace, cx| {
258 workspace.hide_modal(window, cx);
259 workspace.toggle_modal(window, cx, |window, cx| {
260 RemoteConnectionModal::new(&connection_options, paths, window, cx)
261 });
262
263 let ui = workspace
264 .active_modal::<RemoteConnectionModal>(cx)?
265 .read(cx)
266 .prompt
267 .clone();
268
269 ui.update(cx, |ui, _cx| {
270 ui.set_cancellation_tx(cancel_tx);
271 });
272
273 Some(Arc::new(RemoteClientDelegate::new(
274 window.window_handle(),
275 ui.downgrade(),
276 if let RemoteConnectionOptions::Ssh(options) = &connection_options {
277 options
278 .password
279 .as_deref()
280 .and_then(|pw| EncryptedPassword::try_from(pw).ok())
281 } else {
282 None
283 },
284 )))
285 })
286 }
287 })?;
288
289 let Some(delegate) = delegate else { break };
290
291 let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
292 let connection = select! {
293 _ = cancel_rx => {
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 break;
301 },
302 result = connection.fuse() => result,
303 };
304 let remote_connection = match connection {
305 Ok(connection) => connection,
306 Err(e) => {
307 initial_workspace.update(cx, |workspace, cx| {
308 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
309 ui.update(cx, |modal, cx| modal.finished(cx))
310 }
311 });
312 log::error!("Failed to open project: {e:#}");
313 let response = window
314 .update(cx, |_, window, cx| {
315 window.prompt(
316 PromptLevel::Critical,
317 match connection_options {
318 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
319 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
320 RemoteConnectionOptions::Docker(_) => {
321 "Failed to connect to Dev Container"
322 }
323 #[cfg(any(test, feature = "test-support"))]
324 RemoteConnectionOptions::Mock(_) => {
325 "Failed to connect to mock server"
326 }
327 },
328 Some(&format!("{e:#}")),
329 &["Retry", "Cancel"],
330 cx,
331 )
332 })?
333 .await;
334
335 if response == Ok(0) {
336 continue;
337 }
338
339 if created_new_window {
340 window
341 .update(cx, |_, window, _| window.remove_window())
342 .ok();
343 }
344 return Ok(());
345 }
346 };
347
348 let (paths, paths_with_positions) =
349 determine_paths_with_positions(&remote_connection, paths.clone()).await;
350
351 let opened_items = cx
352 .update(|cx| {
353 workspace::open_remote_project_with_new_connection(
354 window,
355 remote_connection,
356 cancel_rx,
357 delegate.clone(),
358 app_state.clone(),
359 paths.clone(),
360 cx,
361 )
362 })
363 .await;
364
365 initial_workspace.update(cx, |workspace, cx| {
366 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
367 ui.update(cx, |modal, cx| modal.finished(cx))
368 }
369 });
370
371 match opened_items {
372 Err(e) => {
373 log::error!("Failed to open project: {e:#}");
374 let response = window
375 .update(cx, |_, window, cx| {
376 window.prompt(
377 PromptLevel::Critical,
378 match connection_options {
379 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
380 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
381 RemoteConnectionOptions::Docker(_) => {
382 "Failed to connect to Dev Container"
383 }
384 #[cfg(any(test, feature = "test-support"))]
385 RemoteConnectionOptions::Mock(_) => {
386 "Failed to connect to mock server"
387 }
388 },
389 Some(&format!("{e:#}")),
390 &["Retry", "Cancel"],
391 cx,
392 )
393 })?
394 .await;
395 if response == Ok(0) {
396 continue;
397 }
398
399 if created_new_window {
400 window
401 .update(cx, |_, window, _| window.remove_window())
402 .ok();
403 }
404 initial_workspace.update(cx, |workspace, cx| {
405 trusted_worktrees::track_worktree_trust(
406 workspace.project().read(cx).worktree_store(),
407 None,
408 None,
409 None,
410 cx,
411 );
412 });
413 }
414
415 Ok(items) => {
416 navigate_to_positions(&window, items, &paths_with_positions, cx);
417 }
418 }
419
420 break;
421 }
422
423 // Register the remote client with extensions. We use `multi_workspace.workspace()` here
424 // (not `initial_workspace`) because `open_remote_project_inner` activated the new remote
425 // workspace, so the active workspace is now the one with the remote project.
426 window
427 .update(cx, |multi_workspace: &mut MultiWorkspace, _, cx| {
428 let workspace = multi_workspace.workspace().clone();
429 workspace.update(cx, |workspace, cx| {
430 if let Some(client) = workspace.project().read(cx).remote_client() {
431 if let Some(extension_store) = ExtensionStore::try_global(cx) {
432 extension_store
433 .update(cx, |store, cx| store.register_remote_client(client, cx));
434 }
435 }
436 });
437 })
438 .ok();
439 Ok(())
440}
441
442pub fn navigate_to_positions(
443 window: &WindowHandle<MultiWorkspace>,
444 items: impl IntoIterator<Item = Option<Box<dyn workspace::item::ItemHandle>>>,
445 positions: &[PathWithPosition],
446 cx: &mut AsyncApp,
447) {
448 for (item, path) in items.into_iter().zip(positions) {
449 let Some(item) = item else {
450 continue;
451 };
452 let Some(row) = path.row else {
453 continue;
454 };
455 if let Some(active_editor) = item.downcast::<Editor>() {
456 window
457 .update(cx, |_, window, cx| {
458 active_editor.update(cx, |editor, cx| {
459 let row = row.saturating_sub(1);
460 let col = path.column.unwrap_or(0).saturating_sub(1);
461 let Some(buffer) = editor.buffer().read(cx).as_singleton() else {
462 return;
463 };
464 let buffer_snapshot = buffer.read(cx).snapshot();
465 let point = buffer_snapshot.point_from_external_input(row, col);
466 editor.go_to_singleton_buffer_point(point, window, cx);
467 });
468 })
469 .ok();
470 }
471 }
472}
473
474pub(crate) async fn determine_paths_with_positions(
475 remote_connection: &Arc<dyn RemoteConnection>,
476 mut paths: Vec<PathBuf>,
477) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
478 let mut paths_with_positions = Vec::<PathWithPosition>::new();
479 for path in &mut paths {
480 if let Some(path_str) = path.to_str() {
481 let path_with_position = PathWithPosition::parse_str(&path_str);
482 if path_with_position.row.is_some() {
483 if !path_exists(&remote_connection, &path).await {
484 *path = path_with_position.path.clone();
485 paths_with_positions.push(path_with_position);
486 continue;
487 }
488 }
489 }
490 paths_with_positions.push(PathWithPosition::from_path(path.clone()))
491 }
492 (paths, paths_with_positions)
493}
494
495async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
496 let Ok(command) = connection.build_command(
497 Some("test".to_string()),
498 &["-e".to_owned(), path.to_string_lossy().to_string()],
499 &Default::default(),
500 None,
501 None,
502 Interactive::No,
503 ) else {
504 return false;
505 };
506 let Ok(mut child) = util::command::new_command(command.program)
507 .args(command.args)
508 .envs(command.env)
509 .spawn()
510 else {
511 return false;
512 };
513 child.status().await.is_ok_and(|status| status.success())
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use extension::ExtensionHostProxy;
520 use fs::FakeFs;
521 use gpui::{AppContext, TestAppContext};
522 use http_client::BlockedHttpClient;
523 use node_runtime::NodeRuntime;
524 use remote::RemoteClient;
525 use remote_server::{HeadlessAppState, HeadlessProject};
526 use serde_json::json;
527 use util::path;
528 use workspace::find_existing_workspace;
529
530 #[gpui::test]
531 async fn test_open_remote_project_with_mock_connection(
532 cx: &mut TestAppContext,
533 server_cx: &mut TestAppContext,
534 ) {
535 let app_state = init_test(cx);
536 let executor = cx.executor();
537
538 cx.update(|cx| {
539 release_channel::init(semver::Version::new(0, 0, 0), cx);
540 });
541 server_cx.update(|cx| {
542 release_channel::init(semver::Version::new(0, 0, 0), cx);
543 });
544
545 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
546
547 let remote_fs = FakeFs::new(server_cx.executor());
548 remote_fs
549 .insert_tree(
550 path!("/project"),
551 json!({
552 "src": {
553 "main.rs": "fn main() {}",
554 },
555 "README.md": "# Test Project",
556 }),
557 )
558 .await;
559
560 server_cx.update(HeadlessProject::init);
561 let http_client = Arc::new(BlockedHttpClient);
562 let node_runtime = NodeRuntime::unavailable();
563 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
564 let proxy = Arc::new(ExtensionHostProxy::new());
565
566 let _headless = server_cx.new(|cx| {
567 HeadlessProject::new(
568 HeadlessAppState {
569 session: server_session,
570 fs: remote_fs.clone(),
571 http_client,
572 node_runtime,
573 languages,
574 extension_host_proxy: proxy,
575 startup_time: std::time::Instant::now(),
576 },
577 false,
578 cx,
579 )
580 });
581
582 drop(connect_guard);
583
584 let paths = vec![PathBuf::from(path!("/project"))];
585 let open_options = workspace::OpenOptions::default();
586
587 let mut async_cx = cx.to_async();
588 let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
589
590 executor.run_until_parked();
591
592 assert!(result.is_ok(), "open_remote_project should succeed");
593
594 let windows = cx.update(|cx| cx.windows().len());
595 assert_eq!(windows, 1, "Should have opened a window");
596
597 let multi_workspace_handle =
598 cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
599
600 multi_workspace_handle
601 .update(cx, |multi_workspace, _, cx| {
602 let workspace = multi_workspace.workspace().clone();
603 workspace.update(cx, |workspace, cx| {
604 let project = workspace.project().read(cx);
605 assert!(project.is_remote(), "Project should be a remote project");
606 });
607 })
608 .unwrap();
609 }
610
611 #[gpui::test]
612 async fn test_reuse_existing_remote_workspace_window(
613 cx: &mut TestAppContext,
614 server_cx: &mut TestAppContext,
615 ) {
616 let app_state = init_test(cx);
617 let executor = cx.executor();
618
619 cx.update(|cx| {
620 release_channel::init(semver::Version::new(0, 0, 0), cx);
621 });
622 server_cx.update(|cx| {
623 release_channel::init(semver::Version::new(0, 0, 0), cx);
624 });
625
626 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
627
628 let remote_fs = FakeFs::new(server_cx.executor());
629 remote_fs
630 .insert_tree(
631 path!("/project"),
632 json!({
633 "src": {
634 "main.rs": "fn main() {}",
635 "lib.rs": "pub fn hello() {}",
636 },
637 "README.md": "# Test Project",
638 }),
639 )
640 .await;
641
642 server_cx.update(HeadlessProject::init);
643 let http_client = Arc::new(BlockedHttpClient);
644 let node_runtime = NodeRuntime::unavailable();
645 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
646 let proxy = Arc::new(ExtensionHostProxy::new());
647
648 let _headless = server_cx.new(|cx| {
649 HeadlessProject::new(
650 HeadlessAppState {
651 session: server_session,
652 fs: remote_fs.clone(),
653 http_client,
654 node_runtime,
655 languages,
656 extension_host_proxy: proxy,
657 startup_time: std::time::Instant::now(),
658 },
659 false,
660 cx,
661 )
662 });
663
664 drop(connect_guard);
665
666 // First open: create a new window for the remote project.
667 let paths = vec![PathBuf::from(path!("/project"))];
668 let mut async_cx = cx.to_async();
669 open_remote_project(
670 opts.clone(),
671 paths,
672 app_state.clone(),
673 workspace::OpenOptions::default(),
674 &mut async_cx,
675 )
676 .await
677 .expect("first open_remote_project should succeed");
678
679 executor.run_until_parked();
680
681 assert_eq!(
682 cx.update(|cx| cx.windows().len()),
683 1,
684 "First open should create exactly one window"
685 );
686
687 let first_window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
688
689 // Verify find_existing_workspace discovers the remote workspace.
690 let search_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
691 let (found, _open_visible) = find_existing_workspace(
692 &search_paths,
693 &workspace::OpenOptions::default(),
694 &SerializedWorkspaceLocation::Remote(opts.clone()),
695 &mut async_cx,
696 )
697 .await;
698
699 assert!(
700 found.is_some(),
701 "find_existing_workspace should locate the existing remote workspace"
702 );
703 let (found_window, _found_workspace) = found.unwrap();
704 assert_eq!(
705 found_window, first_window,
706 "find_existing_workspace should return the same window"
707 );
708
709 // Second open with the same connection options should reuse the window.
710 let second_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
711 open_remote_project(
712 opts.clone(),
713 second_paths,
714 app_state.clone(),
715 workspace::OpenOptions::default(),
716 &mut async_cx,
717 )
718 .await
719 .expect("second open_remote_project should succeed via reuse");
720
721 executor.run_until_parked();
722
723 assert_eq!(
724 cx.update(|cx| cx.windows().len()),
725 1,
726 "Second open should reuse the existing window, not create a new one"
727 );
728
729 let still_first_window =
730 cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
731 assert_eq!(
732 still_first_window, first_window,
733 "The window handle should be the same after reuse"
734 );
735 }
736
737 #[gpui::test]
738 async fn test_reconnect_when_server_not_running(
739 cx: &mut TestAppContext,
740 server_cx: &mut TestAppContext,
741 ) {
742 let app_state = init_test(cx);
743 let executor = cx.executor();
744
745 cx.update(|cx| {
746 release_channel::init(semver::Version::new(0, 0, 0), cx);
747 });
748 server_cx.update(|cx| {
749 release_channel::init(semver::Version::new(0, 0, 0), cx);
750 });
751
752 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
753
754 let remote_fs = FakeFs::new(server_cx.executor());
755 remote_fs
756 .insert_tree(
757 path!("/project"),
758 json!({
759 "src": {
760 "main.rs": "fn main() {}",
761 },
762 }),
763 )
764 .await;
765
766 server_cx.update(HeadlessProject::init);
767 let http_client = Arc::new(BlockedHttpClient);
768 let node_runtime = NodeRuntime::unavailable();
769 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
770 let proxy = Arc::new(ExtensionHostProxy::new());
771
772 let _headless = server_cx.new(|cx| {
773 HeadlessProject::new(
774 HeadlessAppState {
775 session: server_session,
776 fs: remote_fs.clone(),
777 http_client: http_client.clone(),
778 node_runtime: node_runtime.clone(),
779 languages: languages.clone(),
780 extension_host_proxy: proxy.clone(),
781 startup_time: std::time::Instant::now(),
782 },
783 false,
784 cx,
785 )
786 });
787
788 drop(connect_guard);
789
790 // Open the remote project normally.
791 let paths = vec![PathBuf::from(path!("/project"))];
792 let mut async_cx = cx.to_async();
793 open_remote_project(
794 opts.clone(),
795 paths.clone(),
796 app_state.clone(),
797 workspace::OpenOptions::default(),
798 &mut async_cx,
799 )
800 .await
801 .expect("initial open should succeed");
802
803 executor.run_until_parked();
804
805 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
806 let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
807
808 // Force the remote client into ServerNotRunning state (simulates the
809 // scenario where the remote server died and reconnection failed).
810 window
811 .update(cx, |multi_workspace, _, cx| {
812 let workspace = multi_workspace.workspace().clone();
813 workspace.update(cx, |workspace, cx| {
814 let client = workspace
815 .project()
816 .read(cx)
817 .remote_client()
818 .expect("should have remote client");
819 client.update(cx, |client, cx| {
820 client.force_server_not_running(cx);
821 });
822 });
823 })
824 .unwrap();
825
826 executor.run_until_parked();
827
828 // Register a new mock server under the same options so the reconnect
829 // path can establish a fresh connection.
830 let (server_session_2, connect_guard_2) =
831 RemoteClient::fake_server_with_opts(&opts, cx, server_cx);
832
833 let _headless_2 = server_cx.new(|cx| {
834 HeadlessProject::new(
835 HeadlessAppState {
836 session: server_session_2,
837 fs: remote_fs.clone(),
838 http_client,
839 node_runtime,
840 languages,
841 extension_host_proxy: proxy,
842 startup_time: std::time::Instant::now(),
843 },
844 false,
845 cx,
846 )
847 });
848
849 drop(connect_guard_2);
850
851 // Simulate clicking "Reconnect": calls open_remote_project with
852 // replace_window pointing to the existing window.
853 let result = open_remote_project(
854 opts,
855 paths,
856 app_state,
857 workspace::OpenOptions {
858 requesting_window: Some(window),
859 ..Default::default()
860 },
861 &mut async_cx,
862 )
863 .await;
864
865 executor.run_until_parked();
866
867 assert!(
868 result.is_ok(),
869 "reconnect should succeed but got: {:?}",
870 result.err()
871 );
872
873 // Should still be a single window with a working remote project.
874 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
875
876 window
877 .update(cx, |multi_workspace, _, cx| {
878 let workspace = multi_workspace.workspace().clone();
879 workspace.update(cx, |workspace, cx| {
880 assert!(
881 workspace.project().read(cx).is_remote(),
882 "project should be remote after reconnect"
883 );
884 });
885 })
886 .unwrap();
887 }
888
889 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
890 cx.update(|cx| {
891 let state = AppState::test(cx);
892 crate::init(cx);
893 editor::init(cx);
894 state
895 })
896 }
897}