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                },
571                false,
572                cx,
573            )
574        });
575
576        drop(connect_guard);
577
578        let paths = vec![PathBuf::from(path!("/project"))];
579        let open_options = workspace::OpenOptions::default();
580
581        let mut async_cx = cx.to_async();
582        let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
583
584        executor.run_until_parked();
585
586        assert!(result.is_ok(), "open_remote_project should succeed");
587
588        let windows = cx.update(|cx| cx.windows().len());
589        assert_eq!(windows, 1, "Should have opened a window");
590
591        let multi_workspace_handle =
592            cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
593
594        multi_workspace_handle
595            .update(cx, |multi_workspace, _, cx| {
596                let workspace = multi_workspace.workspace().clone();
597                workspace.update(cx, |workspace, cx| {
598                    let project = workspace.project().read(cx);
599                    assert!(project.is_remote(), "Project should be a remote project");
600                });
601            })
602            .unwrap();
603    }
604
605    #[gpui::test]
606    async fn test_reuse_existing_remote_workspace_window(
607        cx: &mut TestAppContext,
608        server_cx: &mut TestAppContext,
609    ) {
610        let app_state = init_test(cx);
611        let executor = cx.executor();
612
613        cx.update(|cx| {
614            release_channel::init(semver::Version::new(0, 0, 0), cx);
615        });
616        server_cx.update(|cx| {
617            release_channel::init(semver::Version::new(0, 0, 0), cx);
618        });
619
620        let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
621
622        let remote_fs = FakeFs::new(server_cx.executor());
623        remote_fs
624            .insert_tree(
625                path!("/project"),
626                json!({
627                    "src": {
628                        "main.rs": "fn main() {}",
629                        "lib.rs": "pub fn hello() {}",
630                    },
631                    "README.md": "# Test Project",
632                }),
633            )
634            .await;
635
636        server_cx.update(HeadlessProject::init);
637        let http_client = Arc::new(BlockedHttpClient);
638        let node_runtime = NodeRuntime::unavailable();
639        let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
640        let proxy = Arc::new(ExtensionHostProxy::new());
641
642        let _headless = server_cx.new(|cx| {
643            HeadlessProject::new(
644                HeadlessAppState {
645                    session: server_session,
646                    fs: remote_fs.clone(),
647                    http_client,
648                    node_runtime,
649                    languages,
650                    extension_host_proxy: proxy,
651                },
652                false,
653                cx,
654            )
655        });
656
657        drop(connect_guard);
658
659        // First open: create a new window for the remote project.
660        let paths = vec![PathBuf::from(path!("/project"))];
661        let mut async_cx = cx.to_async();
662        open_remote_project(
663            opts.clone(),
664            paths,
665            app_state.clone(),
666            workspace::OpenOptions::default(),
667            &mut async_cx,
668        )
669        .await
670        .expect("first open_remote_project should succeed");
671
672        executor.run_until_parked();
673
674        assert_eq!(
675            cx.update(|cx| cx.windows().len()),
676            1,
677            "First open should create exactly one window"
678        );
679
680        let first_window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
681
682        // Verify find_existing_workspace discovers the remote workspace.
683        let search_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
684        let (found, _open_visible) = find_existing_workspace(
685            &search_paths,
686            &workspace::OpenOptions::default(),
687            &SerializedWorkspaceLocation::Remote(opts.clone()),
688            &mut async_cx,
689        )
690        .await;
691
692        assert!(
693            found.is_some(),
694            "find_existing_workspace should locate the existing remote workspace"
695        );
696        let (found_window, _found_workspace) = found.unwrap();
697        assert_eq!(
698            found_window, first_window,
699            "find_existing_workspace should return the same window"
700        );
701
702        // Second open with the same connection options should reuse the window.
703        let second_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
704        open_remote_project(
705            opts.clone(),
706            second_paths,
707            app_state.clone(),
708            workspace::OpenOptions::default(),
709            &mut async_cx,
710        )
711        .await
712        .expect("second open_remote_project should succeed via reuse");
713
714        executor.run_until_parked();
715
716        assert_eq!(
717            cx.update(|cx| cx.windows().len()),
718            1,
719            "Second open should reuse the existing window, not create a new one"
720        );
721
722        let still_first_window =
723            cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
724        assert_eq!(
725            still_first_window, first_window,
726            "The window handle should be the same after reuse"
727        );
728    }
729
730    #[gpui::test]
731    async fn test_reconnect_when_server_not_running(
732        cx: &mut TestAppContext,
733        server_cx: &mut TestAppContext,
734    ) {
735        let app_state = init_test(cx);
736        let executor = cx.executor();
737
738        cx.update(|cx| {
739            release_channel::init(semver::Version::new(0, 0, 0), cx);
740        });
741        server_cx.update(|cx| {
742            release_channel::init(semver::Version::new(0, 0, 0), cx);
743        });
744
745        let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
746
747        let remote_fs = FakeFs::new(server_cx.executor());
748        remote_fs
749            .insert_tree(
750                path!("/project"),
751                json!({
752                    "src": {
753                        "main.rs": "fn main() {}",
754                    },
755                }),
756            )
757            .await;
758
759        server_cx.update(HeadlessProject::init);
760        let http_client = Arc::new(BlockedHttpClient);
761        let node_runtime = NodeRuntime::unavailable();
762        let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
763        let proxy = Arc::new(ExtensionHostProxy::new());
764
765        let _headless = server_cx.new(|cx| {
766            HeadlessProject::new(
767                HeadlessAppState {
768                    session: server_session,
769                    fs: remote_fs.clone(),
770                    http_client: http_client.clone(),
771                    node_runtime: node_runtime.clone(),
772                    languages: languages.clone(),
773                    extension_host_proxy: proxy.clone(),
774                },
775                false,
776                cx,
777            )
778        });
779
780        drop(connect_guard);
781
782        // Open the remote project normally.
783        let paths = vec![PathBuf::from(path!("/project"))];
784        let mut async_cx = cx.to_async();
785        open_remote_project(
786            opts.clone(),
787            paths.clone(),
788            app_state.clone(),
789            workspace::OpenOptions::default(),
790            &mut async_cx,
791        )
792        .await
793        .expect("initial open should succeed");
794
795        executor.run_until_parked();
796
797        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
798        let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
799
800        // Force the remote client into ServerNotRunning state (simulates the
801        // scenario where the remote server died and reconnection failed).
802        window
803            .update(cx, |multi_workspace, _, cx| {
804                let workspace = multi_workspace.workspace().clone();
805                workspace.update(cx, |workspace, cx| {
806                    let client = workspace
807                        .project()
808                        .read(cx)
809                        .remote_client()
810                        .expect("should have remote client");
811                    client.update(cx, |client, cx| {
812                        client.force_server_not_running(cx);
813                    });
814                });
815            })
816            .unwrap();
817
818        executor.run_until_parked();
819
820        // Register a new mock server under the same options so the reconnect
821        // path can establish a fresh connection.
822        let (server_session_2, connect_guard_2) =
823            RemoteClient::fake_server_with_opts(&opts, cx, server_cx);
824
825        let _headless_2 = server_cx.new(|cx| {
826            HeadlessProject::new(
827                HeadlessAppState {
828                    session: server_session_2,
829                    fs: remote_fs.clone(),
830                    http_client,
831                    node_runtime,
832                    languages,
833                    extension_host_proxy: proxy,
834                },
835                false,
836                cx,
837            )
838        });
839
840        drop(connect_guard_2);
841
842        // Simulate clicking "Reconnect": calls open_remote_project with
843        // replace_window pointing to the existing window.
844        let result = open_remote_project(
845            opts,
846            paths,
847            app_state,
848            workspace::OpenOptions {
849                replace_window: Some(window),
850                ..Default::default()
851            },
852            &mut async_cx,
853        )
854        .await;
855
856        executor.run_until_parked();
857
858        assert!(
859            result.is_ok(),
860            "reconnect should succeed but got: {:?}",
861            result.err()
862        );
863
864        // Should still be a single window with a working remote project.
865        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
866
867        window
868            .update(cx, |multi_workspace, _, cx| {
869                let workspace = multi_workspace.workspace().clone();
870                workspace.update(cx, |workspace, cx| {
871                    assert!(
872                        workspace.project().read(cx).is_remote(),
873                        "project should be remote after reconnect"
874                    );
875                });
876            })
877            .unwrap();
878    }
879
880    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
881        cx.update(|cx| {
882            let state = AppState::test(cx);
883            crate::init(cx);
884            editor::init(cx);
885            state
886        })
887    }
888}