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