remote_connections.rs

  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}