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