diff --git a/Cargo.lock b/Cargo.lock index b351d73b5f045ecc2f8e7098d8e31a59871c5c59..f319d08002dd396b0ece871a562dad401fd72174 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index b2026d892759c107c231eebdb30685fe76adff33..78f035106d37faa16a1494a138dfa38ed304dd8d 100644 --- a/crates/agent_ui/Cargo.toml +++ b/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 diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 645e6439b0a9d0ef68f405f622dab39530cec61f..6e728758d0ef93bdd6998f262276e6386cb96f01 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/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, selected_agent: Option, + remote_connection_options: Option, 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 = + 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(); + } + }); } } diff --git a/crates/remote_connection/src/remote_connection.rs b/crates/remote_connection/src/remote_connection.rs index df6260d1c5b3cd1704bfe0ce6a8476bbc0f39670..d622769d90047f4f97338421ba2d62f9c878a46d 100644 --- a/crates/remote_connection/src/remote_connection.rs +++ b/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, + _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, + cx: &mut AsyncApp, + ) -> Task> { + 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, + cx: &mut AsyncApp, + ) -> Task>> { + 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, diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index d76fd139557dd10438d7cf98f9168d87dcae9804..9cca03e10212c38e849be7fa1584bb5f4f4d9351 100644 --- a/crates/sidebar/Cargo.toml +++ b/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"] } diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index eb37c6fd1c22d140ba085631deded64af893aae0..49375e14e411286f0cf436a314c21da2d44f4d01 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/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| ::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" + ); + }); +}