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.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(), 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.replace_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 editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx);
462 });
463 })
464 .ok();
465 }
466 }
467}
468
469pub(crate) async fn determine_paths_with_positions(
470 remote_connection: &Arc<dyn RemoteConnection>,
471 mut paths: Vec<PathBuf>,
472) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
473 let mut paths_with_positions = Vec::<PathWithPosition>::new();
474 for path in &mut paths {
475 if let Some(path_str) = path.to_str() {
476 let path_with_position = PathWithPosition::parse_str(&path_str);
477 if path_with_position.row.is_some() {
478 if !path_exists(&remote_connection, &path).await {
479 *path = path_with_position.path.clone();
480 paths_with_positions.push(path_with_position);
481 continue;
482 }
483 }
484 }
485 paths_with_positions.push(PathWithPosition::from_path(path.clone()))
486 }
487 (paths, paths_with_positions)
488}
489
490async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
491 let Ok(command) = connection.build_command(
492 Some("test".to_string()),
493 &["-e".to_owned(), path.to_string_lossy().to_string()],
494 &Default::default(),
495 None,
496 None,
497 Interactive::No,
498 ) else {
499 return false;
500 };
501 let Ok(mut child) = util::command::new_command(command.program)
502 .args(command.args)
503 .envs(command.env)
504 .spawn()
505 else {
506 return false;
507 };
508 child.status().await.is_ok_and(|status| status.success())
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use extension::ExtensionHostProxy;
515 use fs::FakeFs;
516 use gpui::{AppContext, TestAppContext};
517 use http_client::BlockedHttpClient;
518 use node_runtime::NodeRuntime;
519 use remote::RemoteClient;
520 use remote_server::{HeadlessAppState, HeadlessProject};
521 use serde_json::json;
522 use util::path;
523 use workspace::find_existing_workspace;
524
525 #[gpui::test]
526 async fn test_open_remote_project_with_mock_connection(
527 cx: &mut TestAppContext,
528 server_cx: &mut TestAppContext,
529 ) {
530 let app_state = init_test(cx);
531 let executor = cx.executor();
532
533 cx.update(|cx| {
534 release_channel::init(semver::Version::new(0, 0, 0), cx);
535 });
536 server_cx.update(|cx| {
537 release_channel::init(semver::Version::new(0, 0, 0), cx);
538 });
539
540 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
541
542 let remote_fs = FakeFs::new(server_cx.executor());
543 remote_fs
544 .insert_tree(
545 path!("/project"),
546 json!({
547 "src": {
548 "main.rs": "fn main() {}",
549 },
550 "README.md": "# Test Project",
551 }),
552 )
553 .await;
554
555 server_cx.update(HeadlessProject::init);
556 let http_client = Arc::new(BlockedHttpClient);
557 let node_runtime = NodeRuntime::unavailable();
558 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
559 let proxy = Arc::new(ExtensionHostProxy::new());
560
561 let _headless = server_cx.new(|cx| {
562 HeadlessProject::new(
563 HeadlessAppState {
564 session: server_session,
565 fs: remote_fs.clone(),
566 http_client,
567 node_runtime,
568 languages,
569 extension_host_proxy: proxy,
570 startup_time: std::time::Instant::now(),
571 },
572 false,
573 cx,
574 )
575 });
576
577 drop(connect_guard);
578
579 let paths = vec![PathBuf::from(path!("/project"))];
580 let open_options = workspace::OpenOptions::default();
581
582 let mut async_cx = cx.to_async();
583 let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
584
585 executor.run_until_parked();
586
587 assert!(result.is_ok(), "open_remote_project should succeed");
588
589 let windows = cx.update(|cx| cx.windows().len());
590 assert_eq!(windows, 1, "Should have opened a window");
591
592 let multi_workspace_handle =
593 cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
594
595 multi_workspace_handle
596 .update(cx, |multi_workspace, _, cx| {
597 let workspace = multi_workspace.workspace().clone();
598 workspace.update(cx, |workspace, cx| {
599 let project = workspace.project().read(cx);
600 assert!(project.is_remote(), "Project should be a remote project");
601 });
602 })
603 .unwrap();
604 }
605
606 #[gpui::test]
607 async fn test_reuse_existing_remote_workspace_window(
608 cx: &mut TestAppContext,
609 server_cx: &mut TestAppContext,
610 ) {
611 let app_state = init_test(cx);
612 let executor = cx.executor();
613
614 cx.update(|cx| {
615 release_channel::init(semver::Version::new(0, 0, 0), cx);
616 });
617 server_cx.update(|cx| {
618 release_channel::init(semver::Version::new(0, 0, 0), cx);
619 });
620
621 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
622
623 let remote_fs = FakeFs::new(server_cx.executor());
624 remote_fs
625 .insert_tree(
626 path!("/project"),
627 json!({
628 "src": {
629 "main.rs": "fn main() {}",
630 "lib.rs": "pub fn hello() {}",
631 },
632 "README.md": "# Test Project",
633 }),
634 )
635 .await;
636
637 server_cx.update(HeadlessProject::init);
638 let http_client = Arc::new(BlockedHttpClient);
639 let node_runtime = NodeRuntime::unavailable();
640 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
641 let proxy = Arc::new(ExtensionHostProxy::new());
642
643 let _headless = server_cx.new(|cx| {
644 HeadlessProject::new(
645 HeadlessAppState {
646 session: server_session,
647 fs: remote_fs.clone(),
648 http_client,
649 node_runtime,
650 languages,
651 extension_host_proxy: proxy,
652 startup_time: std::time::Instant::now(),
653 },
654 false,
655 cx,
656 )
657 });
658
659 drop(connect_guard);
660
661 // First open: create a new window for the remote project.
662 let paths = vec![PathBuf::from(path!("/project"))];
663 let mut async_cx = cx.to_async();
664 open_remote_project(
665 opts.clone(),
666 paths,
667 app_state.clone(),
668 workspace::OpenOptions::default(),
669 &mut async_cx,
670 )
671 .await
672 .expect("first open_remote_project should succeed");
673
674 executor.run_until_parked();
675
676 assert_eq!(
677 cx.update(|cx| cx.windows().len()),
678 1,
679 "First open should create exactly one window"
680 );
681
682 let first_window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
683
684 // Verify find_existing_workspace discovers the remote workspace.
685 let search_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
686 let (found, _open_visible) = find_existing_workspace(
687 &search_paths,
688 &workspace::OpenOptions::default(),
689 &SerializedWorkspaceLocation::Remote(opts.clone()),
690 &mut async_cx,
691 )
692 .await;
693
694 assert!(
695 found.is_some(),
696 "find_existing_workspace should locate the existing remote workspace"
697 );
698 let (found_window, _found_workspace) = found.unwrap();
699 assert_eq!(
700 found_window, first_window,
701 "find_existing_workspace should return the same window"
702 );
703
704 // Second open with the same connection options should reuse the window.
705 let second_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
706 open_remote_project(
707 opts.clone(),
708 second_paths,
709 app_state.clone(),
710 workspace::OpenOptions::default(),
711 &mut async_cx,
712 )
713 .await
714 .expect("second open_remote_project should succeed via reuse");
715
716 executor.run_until_parked();
717
718 assert_eq!(
719 cx.update(|cx| cx.windows().len()),
720 1,
721 "Second open should reuse the existing window, not create a new one"
722 );
723
724 let still_first_window =
725 cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
726 assert_eq!(
727 still_first_window, first_window,
728 "The window handle should be the same after reuse"
729 );
730 }
731
732 #[gpui::test]
733 async fn test_reconnect_when_server_not_running(
734 cx: &mut TestAppContext,
735 server_cx: &mut TestAppContext,
736 ) {
737 let app_state = init_test(cx);
738 let executor = cx.executor();
739
740 cx.update(|cx| {
741 release_channel::init(semver::Version::new(0, 0, 0), cx);
742 });
743 server_cx.update(|cx| {
744 release_channel::init(semver::Version::new(0, 0, 0), cx);
745 });
746
747 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
748
749 let remote_fs = FakeFs::new(server_cx.executor());
750 remote_fs
751 .insert_tree(
752 path!("/project"),
753 json!({
754 "src": {
755 "main.rs": "fn main() {}",
756 },
757 }),
758 )
759 .await;
760
761 server_cx.update(HeadlessProject::init);
762 let http_client = Arc::new(BlockedHttpClient);
763 let node_runtime = NodeRuntime::unavailable();
764 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
765 let proxy = Arc::new(ExtensionHostProxy::new());
766
767 let _headless = server_cx.new(|cx| {
768 HeadlessProject::new(
769 HeadlessAppState {
770 session: server_session,
771 fs: remote_fs.clone(),
772 http_client: http_client.clone(),
773 node_runtime: node_runtime.clone(),
774 languages: languages.clone(),
775 extension_host_proxy: proxy.clone(),
776 startup_time: std::time::Instant::now(),
777 },
778 false,
779 cx,
780 )
781 });
782
783 drop(connect_guard);
784
785 // Open the remote project normally.
786 let paths = vec![PathBuf::from(path!("/project"))];
787 let mut async_cx = cx.to_async();
788 open_remote_project(
789 opts.clone(),
790 paths.clone(),
791 app_state.clone(),
792 workspace::OpenOptions::default(),
793 &mut async_cx,
794 )
795 .await
796 .expect("initial open should succeed");
797
798 executor.run_until_parked();
799
800 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
801 let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
802
803 // Force the remote client into ServerNotRunning state (simulates the
804 // scenario where the remote server died and reconnection failed).
805 window
806 .update(cx, |multi_workspace, _, cx| {
807 let workspace = multi_workspace.workspace().clone();
808 workspace.update(cx, |workspace, cx| {
809 let client = workspace
810 .project()
811 .read(cx)
812 .remote_client()
813 .expect("should have remote client");
814 client.update(cx, |client, cx| {
815 client.force_server_not_running(cx);
816 });
817 });
818 })
819 .unwrap();
820
821 executor.run_until_parked();
822
823 // Register a new mock server under the same options so the reconnect
824 // path can establish a fresh connection.
825 let (server_session_2, connect_guard_2) =
826 RemoteClient::fake_server_with_opts(&opts, cx, server_cx);
827
828 let _headless_2 = server_cx.new(|cx| {
829 HeadlessProject::new(
830 HeadlessAppState {
831 session: server_session_2,
832 fs: remote_fs.clone(),
833 http_client,
834 node_runtime,
835 languages,
836 extension_host_proxy: proxy,
837 startup_time: std::time::Instant::now(),
838 },
839 false,
840 cx,
841 )
842 });
843
844 drop(connect_guard_2);
845
846 // Simulate clicking "Reconnect": calls open_remote_project with
847 // replace_window pointing to the existing window.
848 let result = open_remote_project(
849 opts,
850 paths,
851 app_state,
852 workspace::OpenOptions {
853 replace_window: Some(window),
854 ..Default::default()
855 },
856 &mut async_cx,
857 )
858 .await;
859
860 executor.run_until_parked();
861
862 assert!(
863 result.is_ok(),
864 "reconnect should succeed but got: {:?}",
865 result.err()
866 );
867
868 // Should still be a single window with a working remote project.
869 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
870
871 window
872 .update(cx, |multi_workspace, _, cx| {
873 let workspace = multi_workspace.workspace().clone();
874 workspace.update(cx, |workspace, cx| {
875 assert!(
876 workspace.project().read(cx).is_remote(),
877 "project should be remote after reconnect"
878 );
879 });
880 })
881 .unwrap();
882 }
883
884 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
885 cx.update(|cx| {
886 let state = AppState::test(cx);
887 crate::init(cx);
888 editor::init(cx);
889 state
890 })
891 }
892}