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