Detailed changes
@@ -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",
@@ -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
@@ -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();
+ }
+ });
}
}
@@ -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,
@@ -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"] }
@@ -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"
+ );
+ });
+}