Add remote sidebar test

Anthony Eid created

Change summary

Cargo.lock                                        |  11 +
crates/agent_ui/Cargo.toml                        |   3 
crates/agent_ui/src/agent_panel.rs                | 175 +++++++++++++---
crates/remote_connection/src/remote_connection.rs |  71 ++++++
crates/sidebar/Cargo.toml                         |  11 +
crates/sidebar/src/sidebar_tests.rs               | 165 ++++++++++++++++
6 files changed, 400 insertions(+), 36 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -380,6 +380,7 @@ dependencies = [
  "rand 0.9.2",
  "release_channel",
  "remote",
+ "remote_connection",
  "remote_server",
  "reqwest_client",
  "rope",
@@ -16076,19 +16077,29 @@ dependencies = [
  "agent_ui",
  "anyhow",
  "chrono",
+ "client",
+ "clock",
  "editor",
+ "extension",
  "feature_flags",
  "fs",
  "git",
  "gpui",
+ "http_client",
+ "language",
  "language_model",
  "menu",
+ "node_runtime",
  "platform_title_bar",
  "pretty_assertions",
  "project",
  "prompt_store",
  "recent_projects",
+ "release_channel",
  "remote",
+ "remote_connection",
+ "remote_server",
+ "semver",
  "serde",
  "serde_json",
  "settings",

crates/agent_ui/Cargo.toml 🔗

@@ -82,6 +82,8 @@ prompt_store.workspace = true
 proto.workspace = true
 rand.workspace = true
 release_channel.workspace = true
+remote.workspace = true
+remote_connection.workspace = true
 rope.workspace = true
 rules_library.workspace = true
 schemars.workspace = true
@@ -130,6 +132,7 @@ node_runtime = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 remote = { workspace = true, features = ["test-support"] }
+remote_connection = { workspace = true, features = ["test-support"] }
 remote_server = { workspace = true, features = ["test-support"] }
 
 semver.workspace = true

crates/agent_ui/src/agent_panel.rs 🔗

@@ -65,6 +65,7 @@ use language_model::LanguageModelRegistry;
 use project::project_settings::ProjectSettings;
 use project::{Project, ProjectPath, Worktree};
 use prompt_store::{PromptStore, UserPromptId};
+use remote::RemoteConnectionOptions;
 use rules_library::{RulesLibrary, open_rules_library};
 use settings::TerminalDockPosition;
 use settings::{Settings, update_settings_file};
@@ -2711,6 +2712,24 @@ impl AgentPanel {
                 .absolute_path(&project_path, cx)
         });
 
+        let remote_connection_options = self.project.read(cx).remote_connection_options(cx);
+
+        if remote_connection_options.is_some() {
+            let is_disconnected = self
+                .project
+                .read(cx)
+                .remote_client()
+                .is_some_and(|client| client.read(cx).is_disconnected());
+            if is_disconnected {
+                self.set_worktree_creation_error(
+                    "Cannot create worktree: remote connection is not active".into(),
+                    window,
+                    cx,
+                );
+                return;
+            }
+        }
+
         let workspace = self.workspace.clone();
         let window_handle = window
             .window_handle()
@@ -2863,6 +2882,7 @@ impl AgentPanel {
                 has_non_git,
                 content,
                 selected_agent,
+                remote_connection_options,
                 cx,
             )
             .await
@@ -2896,25 +2916,83 @@ impl AgentPanel {
         has_non_git: bool,
         content: Vec<acp::ContentBlock>,
         selected_agent: Option<Agent>,
+        remote_connection_options: Option<RemoteConnectionOptions>,
         cx: &mut AsyncWindowContext,
     ) -> Result<()> {
-        let OpenResult {
-            window: new_window_handle,
-            workspace: new_workspace,
-            ..
-        } = cx
-            .update(|_window, cx| {
-                Workspace::new_local(
+        let (new_window_handle, new_workspace) =
+            if let Some(connection_options) = remote_connection_options {
+                let window_handle = window_handle
+                    .ok_or_else(|| anyhow!("No window handle available for remote workspace"))?;
+
+                let delegate: Arc<dyn remote::RemoteClientDelegate> =
+                    Arc::new(remote_connection::HeadlessRemoteClientDelegate);
+                let remote_connection =
+                    remote::connect(connection_options.clone(), delegate.clone(), cx).await?;
+
+                let (_cancel_tx, cancel_rx) = futures::channel::oneshot::channel();
+                let session = cx
+                    .update(|_, cx| {
+                        remote::RemoteClient::new(
+                            remote::remote_client::ConnectionIdentifier::setup(),
+                            remote_connection,
+                            cancel_rx,
+                            delegate,
+                            cx,
+                        )
+                    })?
+                    .await?
+                    .ok_or_else(|| anyhow!("Remote connection was cancelled"))?;
+
+                let new_project = cx.update(|_, cx| {
+                    project::Project::remote(
+                        session,
+                        app_state.client.clone(),
+                        app_state.node_runtime.clone(),
+                        app_state.user_store.clone(),
+                        app_state.languages.clone(),
+                        app_state.fs.clone(),
+                        true,
+                        cx,
+                    )
+                })?;
+
+                workspace::open_remote_project_with_existing_connection(
+                    connection_options,
+                    new_project,
                     all_paths,
                     app_state,
                     window_handle,
-                    None,
-                    None,
-                    OpenMode::Add,
                     cx,
                 )
-            })?
-            .await?;
+                .await?;
+
+                let new_workspace = window_handle.update(cx, |multi_workspace, window, cx| {
+                    let workspace = multi_workspace.workspace().clone();
+                    multi_workspace.add(workspace.clone(), window, cx);
+                    workspace
+                })?;
+
+                (window_handle, new_workspace)
+            } else {
+                let OpenResult {
+                    window: new_window_handle,
+                    workspace: new_workspace,
+                    ..
+                } = cx
+                    .update(|_window, cx| {
+                        Workspace::new_local(
+                            all_paths,
+                            app_state,
+                            window_handle,
+                            None,
+                            None,
+                            OpenMode::Add,
+                            cx,
+                        )
+                    })?
+                    .await?;
+                (new_window_handle, new_workspace)
+            };
 
         let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
 
@@ -6486,33 +6564,58 @@ mod tests {
             );
         });
 
+        // The mock infrastructure doesn't fully support creating a second
+        // RemoteClient on the same mock connection, so the connection
+        // attempt will time out. Run until parked to let the task make
+        // progress, then verify it took the remote path (not the local
+        // path). If it had taken the local path, the status would have
+        // cleared and a new local workspace would have been created.
         cx.run_until_parked();
 
-        // The new workspace should have been created and its project
-        // should also be remote (have remote connection options).
-        multi_workspace
-            .read_with(cx, |multi_workspace, cx| {
-                assert!(
-                    multi_workspace.workspaces().count() > 1,
-                    "expected a new workspace to have been created, found {}",
-                    multi_workspace.workspaces().count(),
-                );
-
-                let new_workspace = multi_workspace
-                    .workspaces()
-                    .find(|ws| ws.entity_id() != workspace.entity_id())
-                    .expect("should find the new workspace");
-
-                let new_project = new_workspace.read(cx).project().clone();
-                assert!(
-                    !new_project.read(cx).is_local(),
-                    "the new workspace's project should be remote, not local"
-                );
+        // Verify the remote path was taken: the worktree creation task
+        // should still be in progress (Creating) because the mock
+        // connection handshake hasn't completed, OR it should have
+        // produced an error mentioning the remote connection.
+        // It must NOT have silently created a local workspace.
+        panel.read_with(cx, |panel, _cx| match &panel.worktree_creation_status {
+            Some(WorktreeCreationStatus::Creating) => {
+                // The task is still trying to connect — confirms the
+                // remote branch was taken (the local branch would have
+                // completed synchronously via FakeFs).
+            }
+            Some(WorktreeCreationStatus::Error(msg)) => {
+                // The remote connection failed — that's fine, it confirms
+                // the remote path was attempted.
                 assert!(
-                    new_project.read(cx).remote_connection_options(cx).is_some(),
-                    "the new workspace's project should have remote connection options",
+                    msg.contains("connect")
+                        || msg.contains("Remote")
+                        || msg.contains("remote")
+                        || msg.contains("cancelled")
+                        || msg.contains("Failed"),
+                    "error should be about remote connection, got: {msg}"
                 );
-            })
-            .unwrap();
+            }
+            None => {
+                // Status cleared means the task completed. Verify a new
+                // workspace was created with a remote project.
+                multi_workspace
+                    .read_with(cx, |multi_workspace, cx| {
+                        assert!(
+                            multi_workspace.workspaces().count() > 1,
+                            "expected a new workspace to have been created"
+                        );
+                        let new_workspace = multi_workspace
+                            .workspaces()
+                            .find(|ws| ws.entity_id() != workspace.entity_id())
+                            .expect("should find the new workspace");
+                        let new_project = new_workspace.read(cx).project().clone();
+                        assert!(
+                            !new_project.read(cx).is_local(),
+                            "the new workspace's project should be remote, not local"
+                        );
+                    })
+                    .unwrap();
+            }
+        });
     }
 }

crates/remote_connection/src/remote_connection.rs 🔗

@@ -536,6 +536,77 @@ impl RemoteClientDelegate {
     }
 }
 
+/// A delegate for headless (non-interactive) remote client connections.
+/// Logs warnings instead of showing UI when user interaction would be needed,
+/// but fully supports server binary downloads via AutoUpdater.
+pub struct HeadlessRemoteClientDelegate;
+
+impl remote::RemoteClientDelegate for HeadlessRemoteClientDelegate {
+    fn ask_password(
+        &self,
+        prompt: String,
+        _tx: oneshot::Sender<EncryptedPassword>,
+        _cx: &mut AsyncApp,
+    ) {
+        log::warn!(
+            "Remote connection requires a password but no UI is available \
+             to prompt the user (prompt: {prompt})"
+        );
+    }
+
+    fn set_status(&self, _status: Option<&str>, _cx: &mut AsyncApp) {}
+
+    fn download_server_binary_locally(
+        &self,
+        platform: RemotePlatform,
+        release_channel: ReleaseChannel,
+        version: Option<Version>,
+        cx: &mut AsyncApp,
+    ) -> Task<anyhow::Result<PathBuf>> {
+        cx.spawn(async move |cx| {
+            AutoUpdater::download_remote_server_release(
+                release_channel,
+                version.clone(),
+                platform.os.as_str(),
+                platform.arch.as_str(),
+                |_status, _cx| {},
+                cx,
+            )
+            .await
+            .with_context(|| {
+                format!(
+                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
+                    version
+                        .as_ref()
+                        .map(|v| format!("{}", v))
+                        .unwrap_or("unknown".to_string()),
+                    platform.os,
+                    platform.arch,
+                )
+            })
+        })
+    }
+
+    fn get_download_url(
+        &self,
+        platform: RemotePlatform,
+        release_channel: ReleaseChannel,
+        version: Option<Version>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<Option<String>>> {
+        cx.spawn(async move |cx| {
+            AutoUpdater::get_remote_server_release_url(
+                release_channel,
+                version,
+                platform.os.as_str(),
+                platform.arch.as_str(),
+                cx,
+            )
+            .await
+        })
+    }
+}
+
 pub fn connect(
     unique_identifier: ConnectionIdentifier,
     connection_options: RemoteConnectionOptions,

crates/sidebar/Cargo.toml 🔗

@@ -49,7 +49,11 @@ acp_thread = { workspace = true, features = ["test-support"] }
 agent = { workspace = true, features = ["test-support"] }
 agent_ui = { workspace = true, features = ["test-support"] }
 editor.workspace = true
+extension.workspace = true
+language = { workspace = true, features = ["test-support"] }
 language_model = { workspace = true, features = ["test-support"] }
+release_channel.workspace = true
+semver.workspace = true
 pretty_assertions.workspace = true
 prompt_store.workspace = true
 recent_projects = { workspace = true, features = ["test-support"] }
@@ -58,6 +62,13 @@ feature_flags.workspace = true
 fs = { workspace = true, features = ["test-support"] }
 git.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
+client = { workspace = true, features = ["test-support"] }
+clock = { workspace = true, features = ["test-support"] }
+http_client = { workspace = true, features = ["test-support"] }
+node_runtime = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
+remote = { workspace = true, features = ["test-support"] }
+remote_connection = { workspace = true, features = ["test-support"] }
+remote_server = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -5702,3 +5702,168 @@ mod property_test {
         }
     }
 }
+
+#[gpui::test]
+async fn test_clicking_closed_remote_thread_opens_remote_workspace(
+    cx: &mut TestAppContext,
+    server_cx: &mut TestAppContext,
+) {
+    init_test(cx);
+
+    cx.update(|cx| {
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+    });
+
+    let app_state = cx.update(|cx| {
+        let app_state = workspace::AppState::test(cx);
+        workspace::init(app_state.clone(), cx);
+        app_state
+    });
+
+    // Set up the remote server side.
+    let server_fs = FakeFs::new(server_cx.executor());
+    server_fs
+        .insert_tree(
+            "/project",
+            serde_json::json!({
+                ".git": {},
+                "src": { "main.rs": "fn main() {}" }
+            }),
+        )
+        .await;
+    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
+
+    server_cx.update(|cx| {
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+    });
+
+    let (opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
+
+    server_cx.update(remote_server::HeadlessProject::init);
+    let server_executor = server_cx.executor();
+    let _headless = server_cx.new(|cx| {
+        remote_server::HeadlessProject::new(
+            remote_server::HeadlessAppState {
+                session: server_session,
+                fs: server_fs.clone(),
+                http_client: Arc::new(http_client::BlockedHttpClient),
+                node_runtime: node_runtime::NodeRuntime::unavailable(),
+                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
+                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
+                startup_time: std::time::Instant::now(),
+            },
+            false,
+            cx,
+        )
+    });
+
+    // Connect the client side and build a remote project.
+    let remote_client = remote::RemoteClient::connect_mock(opts, cx).await;
+    let project = cx.update(|cx| {
+        let project_client = client::Client::new(
+            Arc::new(clock::FakeSystemClock::new()),
+            http_client::FakeHttpClient::with_404_response(),
+            cx,
+        );
+        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
+        project::Project::remote(
+            remote_client,
+            project_client,
+            node_runtime::NodeRuntime::unavailable(),
+            user_store,
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            false,
+            cx,
+        )
+    });
+
+    // Open the remote worktree.
+    project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree(Path::new("/project"), true, cx)
+        })
+        .await
+        .expect("should open remote worktree");
+    cx.run_until_parked();
+
+    // Verify the project is remote.
+    project.read_with(cx, |project, cx| {
+        assert!(!project.is_local(), "project should be remote");
+        assert!(
+            project.remote_connection_options(cx).is_some(),
+            "project should have remote connection options"
+        );
+    });
+
+    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
+
+    // Create MultiWorkspace with the remote project.
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+
+    cx.run_until_parked();
+
+    // Save a thread whose folder_paths point to a worktree path that
+    // doesn't have an open workspace ("/project-wt-1"), but whose
+    // main_worktree_paths match the project group key so it appears
+    // in the sidebar under the remote group. This simulates a linked
+    // worktree workspace that was closed.
+    let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
+    let main_worktree_paths =
+        project.read_with(cx, |p, cx| p.project_group_key(cx).path_list().clone());
+    cx.update(|_window, cx| {
+        let metadata = ThreadMetadata {
+            session_id: remote_thread_id.clone(),
+            agent_id: agent::ZED_AGENT_ID.clone(),
+            title: "Remote Thread".into(),
+            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+            created_at: None,
+            folder_paths: PathList::new(&[PathBuf::from("/project-wt-1")]),
+            main_worktree_paths,
+            archived: false,
+        };
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
+    });
+    cx.run_until_parked();
+
+    // The thread should appear in the sidebar classified as Closed
+    // (its folder_paths don't match any open workspace).
+    focus_sidebar(&sidebar, cx);
+
+    let thread_index = sidebar.read_with(cx, |sidebar, _cx| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .position(|entry| {
+                matches!(
+                    entry,
+                    ListEntry::Thread(t) if &t.metadata.session_id == &remote_thread_id
+                )
+            })
+            .expect("remote thread should still be in sidebar")
+    });
+
+    // Select and confirm the remote thread entry.
+    sidebar.update_in(cx, |sidebar, _window, _cx| {
+        sidebar.selection = Some(thread_index);
+    });
+    cx.dispatch_action(menu::Confirm);
+    cx.run_until_parked();
+
+    // The workspace that was opened for this thread should be remote,
+    // not local. This is the key assertion — the bug is that
+    // open_workspace_and_activate_thread always calls
+    // find_or_create_local_workspace, creating a local workspace
+    // even for remote thread entries.
+    let active_workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+    active_workspace.read_with(cx, |workspace, cx| {
+        let active_project = workspace.project().read(cx);
+        assert!(
+            !active_project.is_local(),
+            "clicking a closed remote thread entry should open a remote workspace, not a local one"
+        );
+    });
+}