From 3d36d1f2af7427c8b785470cae3599d49adabdd2 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 10 Mar 2026 21:19:05 +0530 Subject: [PATCH 01/32] recent_projects: Fix open project buttons hidden when there are no recent projects (#51207) Release Notes: - N/A --- crates/recent_projects/src/recent_projects.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 548e08eccb49c19551984e6acdd086d78927d614..b5ae7b048276f671da48beaa52b0db5fbcdda61a 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1241,8 +1241,8 @@ impl PickerDelegate for RecentProjectsDelegate { let focus_handle = self.focus_handle.clone(); let popover_style = matches!(self.style, ProjectPickerStyle::Popover); let open_folder_section = matches!( - self.filtered_entries.get(self.selected_index)?, - ProjectPickerEntry::OpenFolder { .. } + self.filtered_entries.get(self.selected_index), + Some(ProjectPickerEntry::OpenFolder { .. }) ); if popover_style { From f4b04af3dc63d23ab0fb70527d7e0938c81ef4d0 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 10 Mar 2026 17:04:48 +0100 Subject: [PATCH 02/32] agent: Allow `NativeAgent` to work with multiple projects (#51202) This removes the assumption that one project <-> one native agent. The native agent now maintains a project per session. We don't make use of this right now, but it will come in handy once we start sharing ACP connections globally. Release Notes: - N/A --- crates/agent/src/agent.rs | 456 ++++++++++++++-------- crates/agent/src/native_agent_server.rs | 8 +- crates/agent/src/tests/mod.rs | 103 ++--- crates/agent_servers/src/agent_servers.rs | 2 +- crates/agent_ui/src/mention_set.rs | 4 +- crates/eval_cli/src/main.rs | 24 +- 6 files changed, 325 insertions(+), 272 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index d9ad55c7127983516dbb5fe0392ef135186b79f7..a62e219b2d075e10e074b55859fc6c366c25523d 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -37,7 +37,8 @@ use futures::channel::{mpsc, oneshot}; use futures::future::Shared; use futures::{FutureExt as _, StreamExt as _, future}; use gpui::{ - App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, + App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task, + WeakEntity, }; use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry}; use project::{Project, ProjectItem, ProjectPath, Worktree}; @@ -65,12 +66,22 @@ pub struct RulesLoadingError { pub message: SharedString, } +struct ProjectState { + project: Entity, + project_context: Entity, + project_context_needs_refresh: watch::Sender<()>, + _maintain_project_context: Task>, + context_server_registry: Entity, + _subscriptions: Vec, +} + /// Holds both the internal Thread and the AcpThread for a session struct Session { /// The internal thread that processes messages thread: Entity, /// The ACP thread that handles protocol communication acp_thread: Entity, + project_id: EntityId, pending_save: Task<()>, _subscriptions: Vec, } @@ -235,79 +246,47 @@ pub struct NativeAgent { /// Session ID -> Session mapping sessions: HashMap, thread_store: Entity, - /// Shared project context for all threads - project_context: Entity, - project_context_needs_refresh: watch::Sender<()>, - _maintain_project_context: Task>, - context_server_registry: Entity, + /// Project-specific state keyed by project EntityId + projects: HashMap, /// Shared templates for all threads templates: Arc, /// Cached model information models: LanguageModels, - project: Entity, prompt_store: Option>, fs: Arc, _subscriptions: Vec, } impl NativeAgent { - pub async fn new( - project: Entity, + pub fn new( thread_store: Entity, templates: Arc, prompt_store: Option>, fs: Arc, - cx: &mut AsyncApp, - ) -> Result> { + cx: &mut App, + ) -> Entity { log::debug!("Creating new NativeAgent"); - let project_context = cx - .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx)) - .await; - - Ok(cx.new(|cx| { - let context_server_store = project.read(cx).context_server_store(); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); - - let mut subscriptions = vec![ - cx.subscribe(&project, Self::handle_project_event), - cx.subscribe( - &LanguageModelRegistry::global(cx), - Self::handle_models_updated_event, - ), - cx.subscribe( - &context_server_store, - Self::handle_context_server_store_updated, - ), - cx.subscribe( - &context_server_registry, - Self::handle_context_server_registry_event, - ), - ]; + cx.new(|cx| { + let mut subscriptions = vec![cx.subscribe( + &LanguageModelRegistry::global(cx), + Self::handle_models_updated_event, + )]; if let Some(prompt_store) = prompt_store.as_ref() { subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) } - let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = - watch::channel(()); Self { sessions: HashMap::default(), thread_store, - project_context: cx.new(|_| project_context), - project_context_needs_refresh: project_context_needs_refresh_tx, - _maintain_project_context: cx.spawn(async move |this, cx| { - Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await - }), - context_server_registry, + projects: HashMap::default(), templates, models: LanguageModels::new(cx), - project, prompt_store, fs, _subscriptions: subscriptions, } - })) + }) } fn new_session( @@ -315,10 +294,10 @@ impl NativeAgent { project: Entity, cx: &mut Context, ) -> Entity { - // Create Thread - // Fetch default model from registry settings + let project_id = self.get_or_create_project_state(&project, cx); + let project_state = &self.projects[&project_id]; + let registry = LanguageModelRegistry::read_global(cx); - // Log available models for debugging let available_count = registry.available_models(cx).count(); log::debug!("Total available models: {}", available_count); @@ -328,21 +307,22 @@ impl NativeAgent { }); let thread = cx.new(|cx| { Thread::new( - project.clone(), - self.project_context.clone(), - self.context_server_registry.clone(), + project, + project_state.project_context.clone(), + project_state.context_server_registry.clone(), self.templates.clone(), default_model, cx, ) }); - self.register_session(thread, cx) + self.register_session(thread, project_id, cx) } fn register_session( &mut self, thread_handle: Entity, + project_id: EntityId, cx: &mut Context, ) -> Entity { let connection = Rc::new(NativeAgentConnection(cx.entity())); @@ -405,12 +385,13 @@ impl NativeAgent { Session { thread: thread_handle, acp_thread: acp_thread.clone(), + project_id, _subscriptions: subscriptions, pending_save: Task::ready(()), }, ); - self.update_available_commands(cx); + self.update_available_commands_for_project(project_id, cx); acp_thread } @@ -419,19 +400,102 @@ impl NativeAgent { &self.models } + fn get_or_create_project_state( + &mut self, + project: &Entity, + cx: &mut Context, + ) -> EntityId { + let project_id = project.entity_id(); + if self.projects.contains_key(&project_id) { + return project_id; + } + + let project_context = cx.new(|_| ProjectContext::new(vec![], vec![])); + self.register_project_with_initial_context(project.clone(), project_context, cx); + if let Some(state) = self.projects.get_mut(&project_id) { + state.project_context_needs_refresh.send(()).ok(); + } + project_id + } + + fn register_project_with_initial_context( + &mut self, + project: Entity, + project_context: Entity, + cx: &mut Context, + ) { + let project_id = project.entity_id(); + + let context_server_store = project.read(cx).context_server_store(); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + + let subscriptions = vec![ + cx.subscribe(&project, Self::handle_project_event), + cx.subscribe( + &context_server_store, + Self::handle_context_server_store_updated, + ), + cx.subscribe( + &context_server_registry, + Self::handle_context_server_registry_event, + ), + ]; + + let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = + watch::channel(()); + + self.projects.insert( + project_id, + ProjectState { + project, + project_context, + project_context_needs_refresh: project_context_needs_refresh_tx, + _maintain_project_context: cx.spawn(async move |this, cx| { + Self::maintain_project_context( + this, + project_id, + project_context_needs_refresh_rx, + cx, + ) + .await + }), + context_server_registry, + _subscriptions: subscriptions, + }, + ); + } + + fn session_project_state(&self, session_id: &acp::SessionId) -> Option<&ProjectState> { + self.sessions + .get(session_id) + .and_then(|session| self.projects.get(&session.project_id)) + } + async fn maintain_project_context( this: WeakEntity, + project_id: EntityId, mut needs_refresh: watch::Receiver<()>, cx: &mut AsyncApp, ) -> Result<()> { while needs_refresh.changed().await.is_ok() { let project_context = this .update(cx, |this, cx| { - Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx) - })? + let state = this + .projects + .get(&project_id) + .context("project state not found")?; + anyhow::Ok(Self::build_project_context( + &state.project, + this.prompt_store.as_ref(), + cx, + )) + })?? .await; this.update(cx, |this, cx| { - this.project_context = cx.new(|_| project_context); + if let Some(state) = this.projects.get_mut(&project_id) { + state.project_context = cx.new(|_| project_context); + } })?; } @@ -620,13 +684,17 @@ impl NativeAgent { fn handle_project_event( &mut self, - _project: Entity, + project: Entity, event: &project::Event, _cx: &mut Context, ) { + let project_id = project.entity_id(); + let Some(state) = self.projects.get_mut(&project_id) else { + return; + }; match event { project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { - self.project_context_needs_refresh.send(()).ok(); + state.project_context_needs_refresh.send(()).ok(); } project::Event::WorktreeUpdatedEntries(_, items) => { if items.iter().any(|(path, _, _)| { @@ -634,7 +702,7 @@ impl NativeAgent { .iter() .any(|name| path.as_ref() == RelPath::unix(name).unwrap()) }) { - self.project_context_needs_refresh.send(()).ok(); + state.project_context_needs_refresh.send(()).ok(); } } _ => {} @@ -647,7 +715,9 @@ impl NativeAgent { _event: &prompt_store::PromptsUpdatedEvent, _cx: &mut Context, ) { - self.project_context_needs_refresh.send(()).ok(); + for state in self.projects.values_mut() { + state.project_context_needs_refresh.send(()).ok(); + } } fn handle_models_updated_event( @@ -677,30 +747,52 @@ impl NativeAgent { fn handle_context_server_store_updated( &mut self, - _store: Entity, + store: Entity, _event: &project::context_server_store::ServerStatusChangedEvent, cx: &mut Context, ) { - self.update_available_commands(cx); + let project_id = self.projects.iter().find_map(|(id, state)| { + if *state.context_server_registry.read(cx).server_store() == store { + Some(*id) + } else { + None + } + }); + if let Some(project_id) = project_id { + self.update_available_commands_for_project(project_id, cx); + } } fn handle_context_server_registry_event( &mut self, - _registry: Entity, + registry: Entity, event: &ContextServerRegistryEvent, cx: &mut Context, ) { match event { ContextServerRegistryEvent::ToolsChanged => {} ContextServerRegistryEvent::PromptsChanged => { - self.update_available_commands(cx); + let project_id = self.projects.iter().find_map(|(id, state)| { + if state.context_server_registry == registry { + Some(*id) + } else { + None + } + }); + if let Some(project_id) = project_id { + self.update_available_commands_for_project(project_id, cx); + } } } } - fn update_available_commands(&self, cx: &mut Context) { - let available_commands = self.build_available_commands(cx); + fn update_available_commands_for_project(&self, project_id: EntityId, cx: &mut Context) { + let available_commands = + Self::build_available_commands_for_project(self.projects.get(&project_id), cx); for session in self.sessions.values() { + if session.project_id != project_id { + continue; + } session.acp_thread.update(cx, |thread, cx| { thread .handle_session_update( @@ -714,8 +806,14 @@ impl NativeAgent { } } - fn build_available_commands(&self, cx: &App) -> Vec { - let registry = self.context_server_registry.read(cx); + fn build_available_commands_for_project( + project_state: Option<&ProjectState>, + cx: &App, + ) -> Vec { + let Some(state) = project_state else { + return vec![]; + }; + let registry = state.context_server_registry.read(cx); let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default(); for context_server_prompt in registry.prompts() { @@ -769,8 +867,10 @@ impl NativeAgent { pub fn load_thread( &mut self, id: acp::SessionId, + project: Entity, cx: &mut Context, ) -> Task>> { + let project_id = self.get_or_create_project_state(&project, cx); let database_future = ThreadsDatabase::connect(cx); cx.spawn(async move |this, cx| { let database = database_future.await.map_err(|err| anyhow!(err))?; @@ -780,41 +880,48 @@ impl NativeAgent { .with_context(|| format!("no thread found with ID: {id:?}"))?; this.update(cx, |this, cx| { + let project_state = this + .projects + .get(&project_id) + .context("project state not found")?; let summarization_model = LanguageModelRegistry::read_global(cx) .thread_summary_model() .map(|c| c.model); - cx.new(|cx| { + Ok(cx.new(|cx| { let mut thread = Thread::from_db( id.clone(), db_thread, - this.project.clone(), - this.project_context.clone(), - this.context_server_registry.clone(), + project_state.project.clone(), + project_state.project_context.clone(), + project_state.context_server_registry.clone(), this.templates.clone(), cx, ); thread.set_summarization_model(summarization_model, cx); thread - }) - }) + })) + })? }) } pub fn open_thread( &mut self, id: acp::SessionId, + project: Entity, cx: &mut Context, ) -> Task>> { if let Some(session) = self.sessions.get(&id) { return Task::ready(Ok(session.acp_thread.clone())); } - let task = self.load_thread(id, cx); + let project_id = self.get_or_create_project_state(&project, cx); + let task = self.load_thread(id, project, cx); cx.spawn(async move |this, cx| { let thread = task.await?; - let acp_thread = - this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?; + let acp_thread = this.update(cx, |this, cx| { + this.register_session(thread.clone(), project_id, cx) + })?; let events = thread.update(cx, |thread, cx| thread.replay(cx)); cx.update(|cx| { NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) @@ -827,9 +934,10 @@ impl NativeAgent { pub fn thread_summary( &mut self, id: acp::SessionId, + project: Entity, cx: &mut Context, ) -> Task> { - let thread = self.open_thread(id.clone(), cx); + let thread = self.open_thread(id.clone(), project, cx); cx.spawn(async move |this, cx| { let acp_thread = thread.await?; let result = this @@ -857,8 +965,13 @@ impl NativeAgent { return; }; + let project_id = session.project_id; + let Some(state) = self.projects.get(&project_id) else { + return; + }; + let folder_paths = PathList::new( - &self + &state .project .read(cx) .visible_worktrees(cx) @@ -889,15 +1002,22 @@ impl NativeAgent { fn send_mcp_prompt( &self, message_id: UserMessageId, - session_id: agent_client_protocol::SessionId, + session_id: acp::SessionId, prompt_name: String, server_id: ContextServerId, arguments: HashMap, original_content: Vec, cx: &mut Context, ) -> Task> { - let server_store = self.context_server_registry.read(cx).server_store().clone(); - let path_style = self.project.read(cx).path_style(cx); + let Some(state) = self.session_project_state(&session_id) else { + return Task::ready(Err(anyhow!("Project state not found for session"))); + }; + let server_store = state + .context_server_registry + .read(cx) + .server_store() + .clone(); + let path_style = state.project.read(cx).path_style(cx); cx.spawn(async move |this, cx| { let prompt = @@ -996,8 +1116,14 @@ impl NativeAgentConnection { .map(|session| session.thread.clone()) } - pub fn load_thread(&self, id: acp::SessionId, cx: &mut App) -> Task>> { - self.0.update(cx, |this, cx| this.load_thread(id, cx)) + pub fn load_thread( + &self, + id: acp::SessionId, + project: Entity, + cx: &mut App, + ) -> Task>> { + self.0 + .update(cx, |this, cx| this.load_thread(id, project, cx)) } fn run_turn( @@ -1279,13 +1405,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn load_session( self: Rc, session_id: acp::SessionId, - _project: Entity, + project: Entity, _cwd: &Path, _title: Option, cx: &mut App, ) -> Task>> { self.0 - .update(cx, |agent, cx| agent.open_thread(session_id, cx)) + .update(cx, |agent, cx| agent.open_thread(session_id, project, cx)) } fn supports_close_session(&self) -> bool { @@ -1294,7 +1420,15 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn close_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task> { self.0.update(cx, |agent, _cx| { + let project_id = agent.sessions.get(session_id).map(|s| s.project_id); agent.sessions.remove(session_id); + + if let Some(project_id) = project_id { + let has_remaining = agent.sessions.values().any(|s| s.project_id == project_id); + if !has_remaining { + agent.projects.remove(&project_id); + } + } }); Task::ready(Ok(())) } @@ -1325,8 +1459,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { log::info!("Received prompt request for session: {}", session_id); log::debug!("Prompt blocks count: {}", params.prompt.len()); + let Some(project_state) = self.0.read(cx).session_project_state(&session_id) else { + return Task::ready(Err(anyhow::anyhow!("Session not found"))); + }; + if let Some(parsed_command) = Command::parse(¶ms.prompt) { - let registry = self.0.read(cx).context_server_registry.read(cx); + let registry = project_state.context_server_registry.read(cx); let explicit_server_id = parsed_command .explicit_server_id @@ -1362,10 +1500,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection { cx, ) }); - }; + } }; - let path_style = self.0.read(cx).project.read(cx).path_style(cx); + let path_style = project_state.project.read(cx).path_style(cx); self.run_turn(session_id, cx, move |thread, cx| { let content: Vec = params @@ -1406,7 +1544,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn truncate( &self, - session_id: &agent_client_protocol::SessionId, + session_id: &acp::SessionId, cx: &App, ) -> Option> { self.0.read_with(cx, |agent, _cx| { @@ -1611,6 +1749,7 @@ impl NativeThreadEnvironment { }; let parent_thread = parent_thread_entity.read(cx); let current_depth = parent_thread.depth(); + let parent_session_id = parent_thread.id().clone(); if current_depth >= MAX_SUBAGENT_DEPTH { return Err(anyhow!( @@ -1627,9 +1766,16 @@ impl NativeThreadEnvironment { let session_id = subagent_thread.read(cx).id().clone(); - let acp_thread = self.agent.update(cx, |agent, cx| { - agent.register_session(subagent_thread.clone(), cx) - })?; + let acp_thread = self + .agent + .update(cx, |agent, cx| -> Result> { + let project_id = agent + .sessions + .get(&parent_session_id) + .map(|s| s.project_id) + .context("parent session not found")?; + Ok(agent.register_session(subagent_thread.clone(), project_id, cx)) + })??; let depth = current_depth + 1; @@ -1955,18 +2101,21 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + + // Creating a session registers the project and triggers context building. + let connection = NativeAgentConnection(agent.clone()); + let _acp_thread = cx + .update(|cx| Rc::new(connection).new_session(project.clone(), Path::new("/"), cx)) + .await + .unwrap(); + cx.run_until_parked(); + agent.read_with(cx, |agent, cx| { - assert_eq!(agent.project_context.read(cx).worktrees, vec![]) + let project_id = project.entity_id(); + let state = agent.projects.get(&project_id).unwrap(); + assert_eq!(state.project_context.read(cx).worktrees, vec![]) }); let worktree = project @@ -1975,8 +2124,10 @@ mod internal_tests { .unwrap(); cx.run_until_parked(); agent.read_with(cx, |agent, cx| { + let project_id = project.entity_id(); + let state = agent.projects.get(&project_id).unwrap(); assert_eq!( - agent.project_context.read(cx).worktrees, + state.project_context.read(cx).worktrees, vec![WorktreeContext { root_name: "a".into(), abs_path: Path::new("/a").into(), @@ -1989,12 +2140,14 @@ mod internal_tests { fs.insert_file("/a/.rules", Vec::new()).await; cx.run_until_parked(); agent.read_with(cx, |agent, cx| { + let project_id = project.entity_id(); + let state = agent.projects.get(&project_id).unwrap(); let rules_entry = worktree .read(cx) .entry_for_path(rel_path(".rules")) .unwrap(); assert_eq!( - agent.project_context.read(cx).worktrees, + state.project_context.read(cx).worktrees, vec![WorktreeContext { root_name: "a".into(), abs_path: Path::new("/a").into(), @@ -2015,18 +2168,10 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let connection = NativeAgentConnection( - NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(), - ); + let connection = + NativeAgentConnection(cx.update(|cx| { + NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx) + })); // Create a thread/session let acp_thread = cx @@ -2095,16 +2240,8 @@ mod internal_tests { let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create the agent and connection - let agent = NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread/session @@ -2196,16 +2333,8 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); let acp_thread = cx @@ -2288,16 +2417,9 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a thinking model. @@ -2371,7 +2493,9 @@ mod internal_tests { // Reload the thread and verify thinking_enabled is still true. let reloaded_acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) .await .unwrap(); let reloaded_thread = agent.read_with(cx, |agent, _| { @@ -2394,16 +2518,9 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a model where id() != name(), like real Anthropic models @@ -2478,7 +2595,9 @@ mod internal_tests { // Reload the thread and verify the model was preserved. let reloaded_acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) .await .unwrap(); let reloaded_thread = agent.read_with(cx, |agent, _| { @@ -2513,16 +2632,9 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -2642,7 +2754,9 @@ mod internal_tests { )] ); let acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) .await .unwrap(); acp_thread.read_with(cx, |thread, cx| { diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 18c41670ac4b4ba3146fb207992a7020a44fbd5f..ca5128fc80d49df0f165ab065a510585400f55d9 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -35,11 +35,10 @@ impl AgentServer for NativeAgentServer { fn connect( &self, - delegate: AgentServerDelegate, + _delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { log::debug!("NativeAgentServer::connect"); - let project = delegate.project().clone(); let fs = self.fs.clone(); let thread_store = self.thread_store.clone(); let prompt_store = PromptStore::global(cx); @@ -49,9 +48,8 @@ impl AgentServer for NativeAgentServer { let prompt_store = prompt_store.await?; log::debug!("Creating native agent entity"); - let agent = - NativeAgent::new(project, thread_store, templates, Some(prompt_store), fs, cx) - .await?; + let agent = cx + .update(|cx| NativeAgent::new(thread_store, templates, Some(prompt_store), fs, cx)); // Create the connection wrapper let connection = NativeAgentConnection(agent); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index d33c80a435e84359976d4d8a9edb2bdebd66e0ff..db3fa7c56ebc8ba7a94850d9d38b07c65a7ef4ba 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3181,16 +3181,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create agent and connection - let agent = NativeAgent::new( - project.clone(), - thread_store, - templates.clone(), - None, - fake_fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx + .update(|cx| NativeAgent::new(thread_store, templates.clone(), None, fake_fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread using new_thread @@ -4388,16 +4380,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4530,16 +4515,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4685,16 +4663,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4822,16 +4793,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5201,16 +5165,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5334,16 +5291,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5515,16 +5465,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index a07226ca25095fdb7037114d32d5033364a4999f..adcbd923c2aecbd88e71037591687acda9f57fac 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -9,12 +9,12 @@ use collections::{HashMap, HashSet}; pub use custom::*; use fs::Fs; use http_client::read_no_proxy_from_env; +use project::Project; use project::agent_server_store::AgentServerStore; use acp_thread::AgentConnection; use anyhow::Result; use gpui::{App, AppContext, Entity, SharedString, Task}; -use project::Project; use settings::SettingsStore; use std::{any::Any, rc::Rc, sync::Arc}; diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 5a76e2b355c3373ee278b0f0de95ddcfcdd13101..dc9d793a5ca5012ca2fe719f1e39bb3fc4fa6d66 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -565,7 +565,9 @@ impl MentionSet { let agent = agent.downcast::().unwrap(); let summary = agent .0 - .update(cx, |agent, cx| agent.thread_summary(id, cx)) + .update(cx, |agent, cx| { + agent.thread_summary(id, project.clone(), cx) + }) .await?; Ok(Mention::Text { content: summary.to_string(), diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index 0f8dbed7ba12cee934e7631dc7068c83db1dc293..7b9f822a539c8d1e0a29bdef0bccee5d4a55721e 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -357,20 +357,16 @@ async fn run_agent( Err(e) => return (Err(e), None), }; - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = match NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - app_state.fs.clone(), - cx, - ) - .await - { - Ok(a) => a, - Err(e) => return (Err(e).context("creating agent"), None), - }; + let agent = cx.update(|cx| { + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + NativeAgent::new( + thread_store, + Templates::new(), + None, + app_state.fs.clone(), + cx, + ) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = match cx From 50aef1f115493aab506df9d5b33da5435dc36bfc Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:22:03 -0600 Subject: [PATCH 03/32] buffer: Reload after undo when file changed while dirty (#51037) Closes #48697 Supersedes #48698 Related to #38109 ## Problem If you edit a file and an external tool writes to it while you have unsaved changes, Zed tracks the new file but skips the reload to preserve your edits. If you then undo everything, the buffer goes back to clean but still shows the old content. The disk has moved on, but nothing triggers a reload. ## Fix In `did_edit()`, when the buffer transitions from dirty to clean, check if the file's mtime changed while it was dirty. If so, emit `ReloadNeeded`. Only fires for files that still exist on disk (`DiskState::Present`). 7 lines in `crates/language/src/buffer.rs`. ### No double reload `file_updated()` suppresses `ReloadNeeded` when the buffer is dirty (that's the whole bug). So by the time `did_edit()` fires on dirty-to-clean, no prior reload was emitted for this file change. The two paths are mutually exclusive. ## Test plan - New: `test_dirty_buffer_reloads_after_undo` - No regression in `test_buffer_is_dirty` or other buffer tests - All project integration tests pass - clippy clean Release Notes: - Fixed an issue where buffer content could become stale after undoing edits when an external tool wrote to the file while the buffer was dirty. Co-authored-by: Claude Opus 4.6 Co-authored-by: Ben Kunkle --- crates/language/src/buffer.rs | 12 +++- .../tests/integration/project_tests.rs | 69 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index a8bf8dd83ca76f8e9bd9892c1355ca8a7835867a..f92ae2419edf61aaa20643c3f87dac2f4af8bf4e 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -2859,9 +2859,19 @@ impl Buffer { self.reparse(cx, true); cx.emit(BufferEvent::Edited { is_local }); - if was_dirty != self.is_dirty() { + let is_dirty = self.is_dirty(); + if was_dirty != is_dirty { cx.emit(BufferEvent::DirtyChanged); } + if was_dirty && !is_dirty { + if let Some(file) = self.file.as_ref() { + if matches!(file.disk_state(), DiskState::Present { .. }) + && file.disk_state().mtime() != self.saved_mtime + { + cx.emit(BufferEvent::ReloadNeeded); + } + } + } cx.notify(); } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 2cecc5054df29b024530e39b6bf61f74c64fa850..0080236758214b284b74abc2f1831b9f9978241e 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -5687,6 +5687,75 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { cx.update(|cx| assert!(buffer3.read(cx).is_dirty())); } +#[gpui::test] +async fn test_dirty_buffer_reloads_after_undo(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "file.txt": "version 1", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let buffer = project + .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file.txt"), cx)) + .await + .unwrap(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "version 1"); + assert!(!buffer.is_dirty()); + }); + + // User makes an edit, making the buffer dirty. + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "user edit: ")], None, cx); + }); + + buffer.read_with(cx, |buffer, _| { + assert!(buffer.is_dirty()); + assert_eq!(buffer.text(), "user edit: version 1"); + }); + + // External tool writes new content while buffer is dirty. + // file_updated() updates the File but suppresses ReloadNeeded. + fs.save( + path!("/dir/file.txt").as_ref(), + &"version 2 from external tool".into(), + Default::default(), + ) + .await + .unwrap(); + cx.executor().run_until_parked(); + + buffer.read_with(cx, |buffer, _| { + assert!(buffer.has_conflict()); + assert_eq!(buffer.text(), "user edit: version 1"); + }); + + // User undoes their edit. Buffer becomes clean, but disk has different + // content. did_edit() detects the dirty->clean transition and checks if + // disk changed while dirty. Since mtime differs from saved_mtime, it + // emits ReloadNeeded. + buffer.update(cx, |buffer, cx| { + buffer.undo(cx); + }); + cx.executor().run_until_parked(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "version 2 from external tool", + "buffer should reload from disk after undo makes it clean" + ); + assert!(!buffer.is_dirty()); + }); +} + #[gpui::test] async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) { init_test(cx); From e4b3c0fa84c668def9db2c1827c834b4391012b6 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 10 Mar 2026 18:32:51 +0100 Subject: [PATCH 04/32] agent: Re-use ACP connections per project (#51209) Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- crates/agent_servers/src/agent_servers.rs | 11 -- crates/agent_servers/src/custom.rs | 1 - crates/agent_servers/src/e2e_tests.rs | 2 +- crates/agent_ui/src/agent_connection_store.rs | 163 +++++++++++++++++ crates/agent_ui/src/agent_panel.rs | 9 +- crates/agent_ui/src/agent_ui.rs | 3 +- crates/agent_ui/src/connection_view.rs | 173 +++++++++++------- crates/agent_ui/src/mention_set.rs | 8 +- crates/project/src/agent_server_store.rs | 98 +++------- .../tests/integration/ext_agent_tests.rs | 1 - .../integration/extension_agent_tests.rs | 1 - crates/proto/proto/ai.proto | 2 +- .../remote_server/src/remote_editing_tests.rs | 1 - crates/sidebar/Cargo.toml | 3 +- crates/sidebar/src/sidebar.rs | 11 +- 15 files changed, 313 insertions(+), 174 deletions(-) create mode 100644 crates/agent_ui/src/agent_connection_store.rs diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index adcbd923c2aecbd88e71037591687acda9f57fac..a12b63164325cfc447e44b3a5899e79b774e141f 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -9,7 +9,6 @@ use collections::{HashMap, HashSet}; pub use custom::*; use fs::Fs; use http_client::read_no_proxy_from_env; -use project::Project; use project::agent_server_store::AgentServerStore; use acp_thread::AgentConnection; @@ -22,29 +21,19 @@ pub use acp::AcpConnection; pub struct AgentServerDelegate { store: Entity, - project: Entity, - status_tx: Option>, new_version_available: Option>>, } impl AgentServerDelegate { pub fn new( store: Entity, - project: Entity, - status_tx: Option>, new_version_tx: Option>>, ) -> Self { Self { store, - project, - status_tx, new_version_available: new_version_tx, } } - - pub fn project(&self) -> &Entity { - &self.project - } } pub trait AgentServer: Send { diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 0a1830717217872868e66a8222902c49eeaabf9c..d87b9dc4ece042d94da6e6e0ac99e1474c1ce018 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -364,7 +364,6 @@ impl AgentServer for CustomAgentServer { })?; anyhow::Ok(agent.get_command( extra_env, - delegate.status_tx, delegate.new_version_available, &mut cx.to_async(), )) diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index a0150d41726c94dc830be70e006f4370de919ead..5dcf416bb87ba4812e1a828c23d49819f2874a99 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -431,7 +431,7 @@ pub async fn new_test_thread( cx: &mut TestAppContext, ) -> Entity { let store = project.read_with(cx, |project, _| project.agent_server_store().clone()); - let delegate = AgentServerDelegate::new(store, project.clone(), None, None); + let delegate = AgentServerDelegate::new(store, None); let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap(); diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..c0c4519bcc64d53690dd782a55e6b9da4f498fe0 --- /dev/null +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -0,0 +1,163 @@ +use std::rc::Rc; + +use acp_thread::{AgentConnection, LoadError}; +use agent_servers::{AgentServer, AgentServerDelegate}; +use anyhow::Result; +use collections::HashMap; +use futures::{FutureExt, future::Shared}; +use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task}; +use project::{AgentServerStore, AgentServersUpdated, Project}; +use watch::Receiver; + +use crate::ExternalAgent; +use project::ExternalAgentServerName; + +pub enum ConnectionEntry { + Connecting { + connect_task: Shared, LoadError>>>, + }, + Connected { + connection: Rc, + }, + Error { + error: LoadError, + }, +} + +impl ConnectionEntry { + pub fn wait_for_connection(&self) -> Shared, LoadError>>> { + match self { + ConnectionEntry::Connecting { connect_task } => connect_task.clone(), + ConnectionEntry::Connected { connection } => { + Task::ready(Ok(connection.clone())).shared() + } + ConnectionEntry::Error { error } => Task::ready(Err(error.clone())).shared(), + } + } +} + +pub enum ConnectionEntryEvent { + NewVersionAvailable(SharedString), +} + +impl EventEmitter for ConnectionEntry {} + +pub struct AgentConnectionStore { + project: Entity, + entries: HashMap>, + _subscriptions: Vec, +} + +impl AgentConnectionStore { + pub fn new(project: Entity, cx: &mut Context) -> Self { + let agent_server_store = project.read(cx).agent_server_store().clone(); + let subscription = cx.subscribe(&agent_server_store, Self::handle_agent_servers_updated); + Self { + project, + entries: HashMap::default(), + _subscriptions: vec![subscription], + } + } + + pub fn request_connection( + &mut self, + key: ExternalAgent, + server: Rc, + cx: &mut Context, + ) -> Entity { + self.entries.get(&key).cloned().unwrap_or_else(|| { + let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx); + let connect_task = connect_task.shared(); + + let entry = cx.new(|_cx| ConnectionEntry::Connecting { + connect_task: connect_task.clone(), + }); + + self.entries.insert(key.clone(), entry.clone()); + + cx.spawn({ + let key = key.clone(); + let entry = entry.clone(); + async move |this, cx| match connect_task.await { + Ok(connection) => { + entry.update(cx, |entry, cx| { + if let ConnectionEntry::Connecting { .. } = entry { + *entry = ConnectionEntry::Connected { connection }; + cx.notify(); + } + }); + } + Err(error) => { + entry.update(cx, |entry, cx| { + if let ConnectionEntry::Connecting { .. } = entry { + *entry = ConnectionEntry::Error { error }; + cx.notify(); + } + }); + this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); + } + } + }) + .detach(); + + cx.spawn({ + let entry = entry.clone(); + async move |this, cx| { + while let Ok(version) = new_version_rx.recv().await { + if let Some(version) = version { + entry.update(cx, |_entry, cx| { + cx.emit(ConnectionEntryEvent::NewVersionAvailable( + version.clone().into(), + )); + }); + this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); + } + } + } + }) + .detach(); + + entry + }) + } + + fn handle_agent_servers_updated( + &mut self, + store: Entity, + _: &AgentServersUpdated, + cx: &mut Context, + ) { + let store = store.read(cx); + self.entries.retain(|key, _| match key { + ExternalAgent::NativeAgent => true, + ExternalAgent::Custom { name } => store + .external_agents + .contains_key(&ExternalAgentServerName(name.clone())), + }); + cx.notify(); + } + + fn start_connection( + &self, + server: Rc, + cx: &mut Context, + ) -> ( + Receiver>, + Task, LoadError>>, + ) { + let (new_version_tx, new_version_rx) = watch::channel::>(None); + + let agent_server_store = self.project.read(cx).agent_server_store().clone(); + let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx)); + + let connect_task = server.connect(delegate, cx); + let connect_task = cx.spawn(async move |_this, _cx| match connect_task.await { + Ok(connection) => Ok(connection), + Err(err) => match err.downcast::() { + Ok(load_error) => Err(load_error), + Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))), + }, + }); + (new_version_rx, connect_task) + } +} diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 2b9f2f5624072f7b9c9f01f1daecd7e1103c758b..80f8925ad05414b9839ac53953156ef35c43e08f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -30,6 +30,7 @@ use zed_actions::agent::{ }; use crate::ManageProfiles; +use crate::agent_connection_store::AgentConnectionStore; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow, @@ -790,6 +791,7 @@ pub struct AgentPanel { thread_store: Entity, text_thread_store: Entity, prompt_store: Option>, + connection_store: Entity, context_server_registry: Entity, configuration: Option>, configuration_subscription: Option, @@ -1116,6 +1118,7 @@ impl AgentPanel { language_registry, text_thread_store, prompt_store, + connection_store: cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)), configuration: None, configuration_subscription: None, focus_handle: cx.focus_handle(), @@ -2395,7 +2398,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let selected_agent = AgentType::from(ext_agent); + let selected_agent = AgentType::from(ext_agent.clone()); if self.selected_agent != selected_agent { self.selected_agent = selected_agent; self.serialize(cx); @@ -2406,9 +2409,13 @@ impl AgentPanel { .is_some() .then(|| self.thread_store.clone()); + let connection_store = self.connection_store.clone(); + let server_view = cx.new(|cx| { crate::ConnectionView::new( server, + connection_store, + ext_agent, resume_session_id, cwd, title, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 8583e8977a719987b12770eec2d77408187a4e1f..d37dbdbbeb184cac31320b4bc9232354eb3dcc8d 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,4 +1,5 @@ mod agent_configuration; +pub(crate) mod agent_connection_store; mod agent_diff; mod agent_model_selector; mod agent_panel; @@ -212,7 +213,7 @@ pub struct NewNativeAgentThreadFromSummary { } // TODO unify this with AgentType -#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ExternalAgent { NativeAgent, diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 07841c42215795ffcccf9f7e5ca684f42a59b498..3b07929813e5583164700905a1fa327f3ac9d964 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -8,7 +8,9 @@ use acp_thread::{AgentConnection, Plan}; use action_log::{ActionLog, ActionLogTelemetry}; use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; use agent_client_protocol::{self as acp, PromptCapabilities}; -use agent_servers::{AgentServer, AgentServerDelegate}; +use agent_servers::AgentServer; +#[cfg(test)] +use agent_servers::AgentServerDelegate; use agent_settings::{AgentProfileId, AgentSettings}; use anyhow::{Result, anyhow}; use arrayvec::ArrayVec; @@ -65,6 +67,7 @@ use super::entry_view_state::EntryViewState; use super::thread_history::ThreadHistory; use crate::ModeSelector; use crate::ModelSelectorPopover; +use crate::agent_connection_store::{AgentConnectionStore, ConnectionEntryEvent}; use crate::agent_diff::AgentDiff; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; @@ -73,10 +76,10 @@ use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, - EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu, - OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage, SendImmediately, - SendNextQueuedMessage, ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu, - ToggleThinkingMode, UndoLastReject, + EditFirstQueuedMessage, ExpandMessageEditor, ExternalAgent, Follow, KeepAll, NewThread, + OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, + RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode, + ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject, }; const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30); @@ -303,6 +306,8 @@ impl EventEmitter for ConnectionView {} pub struct ConnectionView { agent: Rc, + connection_store: Entity, + connection_key: ExternalAgent, agent_server_store: Entity, workspace: WeakEntity, project: Entity, @@ -414,6 +419,7 @@ pub struct ConnectedServerState { threads: HashMap>, connection: Rc, conversation: Entity, + _connection_entry_subscription: Subscription, } enum AuthState { @@ -434,9 +440,7 @@ impl AuthState { struct LoadingView { session_id: Option, - title: SharedString, _load_task: Task<()>, - _update_title_task: Task>, } impl ConnectedServerState { @@ -470,6 +474,8 @@ impl ConnectedServerState { impl ConnectionView { pub fn new( agent: Rc, + connection_store: Entity, + connection_key: ExternalAgent, resume_session_id: Option, cwd: Option, title: Option, @@ -509,6 +515,8 @@ impl ConnectionView { Self { agent: agent.clone(), + connection_store: connection_store.clone(), + connection_key: connection_key.clone(), agent_server_store, workspace, project: project.clone(), @@ -516,6 +524,8 @@ impl ConnectionView { prompt_store, server_state: Self::initial_state( agent.clone(), + connection_store, + connection_key, resume_session_id, cwd, title, @@ -558,6 +568,8 @@ impl ConnectionView { let state = Self::initial_state( self.agent.clone(), + self.connection_store.clone(), + self.connection_key.clone(), resume_session_id, cwd, title, @@ -584,6 +596,8 @@ impl ConnectionView { fn initial_state( agent: Rc, + connection_store: Entity, + connection_key: ExternalAgent, resume_session_id: Option, cwd: Option, title: Option, @@ -640,29 +654,31 @@ impl ConnectionView { .or_else(|| worktree_roots.first().cloned()) .unwrap_or_else(|| paths::home_dir().as_path().into()); - let (status_tx, mut status_rx) = watch::channel("Loading…".into()); - let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None); - let delegate = AgentServerDelegate::new( - project.read(cx).agent_server_store().clone(), - project.clone(), - Some(status_tx), - Some(new_version_available_tx), - ); + let connection_entry = connection_store.update(cx, |store, cx| { + store.request_connection(connection_key, agent.clone(), cx) + }); + + let connection_entry_subscription = + cx.subscribe(&connection_entry, |this, _entry, event, cx| match event { + ConnectionEntryEvent::NewVersionAvailable(version) => { + if let Some(thread) = this.active_thread() { + thread.update(cx, |thread, cx| { + thread.new_server_version_available = Some(version.clone()); + cx.notify(); + }); + } + } + }); + + let connect_result = connection_entry.read(cx).wait_for_connection(); - let connect_task = agent.connect(delegate, cx); let load_session_id = resume_session_id.clone(); let load_task = cx.spawn_in(window, async move |this, cx| { - let connection = match connect_task.await { + let connection = match connect_result.await { Ok(connection) => connection, Err(err) => { this.update_in(cx, |this, window, cx| { - if err.downcast_ref::().is_some() { - this.handle_load_error(load_session_id.clone(), err, window, cx); - } else if let Some(active) = this.active_thread() { - active.update(cx, |active, cx| active.handle_thread_error(err, cx)); - } else { - this.handle_load_error(load_session_id.clone(), err, window, cx); - } + this.handle_load_error(load_session_id.clone(), err, window, cx); cx.notify(); }) .log_err(); @@ -776,52 +792,27 @@ impl ConnectionView { active_id: Some(id.clone()), threads: HashMap::from_iter([(id, current)]), conversation, + _connection_entry_subscription: connection_entry_subscription, }), cx, ); } Err(err) => { - this.handle_load_error(load_session_id.clone(), err, window, cx); + this.handle_load_error( + load_session_id.clone(), + LoadError::Other(err.to_string().into()), + window, + cx, + ); } }; }) .log_err(); }); - cx.spawn(async move |this, cx| { - while let Ok(new_version) = new_version_available_rx.recv().await { - if let Some(new_version) = new_version { - this.update(cx, |this, cx| { - if let Some(thread) = this.active_thread() { - thread.update(cx, |thread, _cx| { - thread.new_server_version_available = Some(new_version.into()); - }); - } - cx.notify(); - }) - .ok(); - } - } - }) - .detach(); - - let loading_view = cx.new(|cx| { - let update_title_task = cx.spawn(async move |this, cx| { - loop { - let status = status_rx.recv().await?; - this.update(cx, |this: &mut LoadingView, cx| { - this.title = status; - cx.notify(); - })?; - } - }); - - LoadingView { - session_id: resume_session_id, - title: "Loading…".into(), - _load_task: load_task, - _update_title_task: update_title_task, - } + let loading_view = cx.new(|_cx| LoadingView { + session_id: resume_session_id, + _load_task: load_task, }); ServerState::Loading(loading_view) @@ -1099,6 +1090,7 @@ impl ConnectionView { threads: HashMap::default(), connection, conversation: cx.new(|_cx| Conversation::default()), + _connection_entry_subscription: Subscription::new(|| {}), }), cx, ); @@ -1111,7 +1103,7 @@ impl ConnectionView { fn handle_load_error( &mut self, session_id: Option, - err: anyhow::Error, + err: LoadError, window: &mut Window, cx: &mut Context, ) { @@ -1125,15 +1117,10 @@ impl ConnectionView { self.focus_handle.focus(window, cx) } } - let load_error = if let Some(load_err) = err.downcast_ref::() { - load_err.clone() - } else { - LoadError::Other(format!("{:#}", err).into()) - }; - self.emit_load_error_telemetry(&load_error); + self.emit_load_error_telemetry(&err); self.set_server_state( ServerState::LoadError { - error: load_error, + error: err, session_id, }, cx, @@ -1172,10 +1159,10 @@ impl ConnectionView { &self.workspace } - pub fn title(&self, cx: &App) -> SharedString { + pub fn title(&self, _cx: &App) -> SharedString { match &self.server_state { ServerState::Connected(_) => "New Thread".into(), - ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(), + ServerState::Loading(_) => "Loading…".into(), ServerState::LoadError { error, .. } => match error { LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), LoadError::FailedToInstall(_) => { @@ -2910,11 +2897,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); // Create history without an initial session list - it will be set after connection let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::default_response()), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, None, None, None, @@ -3010,11 +3003,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, Some(SessionId::new("resume-session")), None, None, @@ -3063,11 +3062,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let _thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, Some(SessionId::new("session-1")), Some(PathBuf::from("/project/subdir")), None, @@ -3114,11 +3119,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let _thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, Some(SessionId::new("session-1")), Some(PathBuf::from("/some/other/path")), None, @@ -3165,11 +3176,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let _thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, Some(SessionId::new("session-1")), Some(PathBuf::from("/project/../outside")), None, @@ -3477,12 +3494,18 @@ pub(crate) mod tests { // Set up thread view in workspace 1 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx))); let agent = StubAgentServer::default_response(); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(agent), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, None, None, None, @@ -3691,11 +3714,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(agent), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, None, None, None, @@ -4410,12 +4439,18 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let connection = Rc::new(StubAgentConnection::new()); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection.as_ref().clone())), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, None, None, None, diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index dc9d793a5ca5012ca2fe719f1e39bb3fc4fa6d66..e072037f1758e00e648dc46c7ee70599c4363eef 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -553,12 +553,8 @@ impl MentionSet { project.read(cx).fs().clone(), thread_store, )); - let delegate = AgentServerDelegate::new( - project.read(cx).agent_server_store().clone(), - project.clone(), - None, - None, - ); + let delegate = + AgentServerDelegate::new(project.read(cx).agent_server_store().clone(), None); let connection = server.connect(delegate, cx); cx.spawn(async move |_, cx| { let agent = connection.await?; diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index b1dbefa15a3dcaf64c36d027d68060d18f533def..4a7c2b03a4e03ddfa31bed24254ebe275a17c224 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -100,7 +100,6 @@ pub trait ExternalAgentServer { fn get_command( &mut self, extra_env: HashMap, - status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task>; @@ -243,7 +242,6 @@ impl AgentServerStore { project_id: *project_id, upstream_client: upstream_client.clone(), name: agent_server_name.clone(), - status_tx: None, new_version_available_tx: None, }) as Box, @@ -347,7 +345,6 @@ impl AgentServerStore { pub fn init_remote(session: &AnyProtoClient) { session.add_entity_message_handler(Self::handle_external_agents_updated); - session.add_entity_message_handler(Self::handle_loading_status_updated); session.add_entity_message_handler(Self::handle_new_version_available); } @@ -695,57 +692,38 @@ impl AgentServerStore { .get_mut(&*envelope.payload.name) .map(|entry| entry.server.as_mut()) .with_context(|| format!("agent `{}` not found", envelope.payload.name))?; - let (status_tx, new_version_available_tx) = downstream_client - .clone() - .map(|(project_id, downstream_client)| { - let (status_tx, mut status_rx) = watch::channel(SharedString::from("")); - let (new_version_available_tx, mut new_version_available_rx) = - watch::channel(None); - cx.spawn({ - let downstream_client = downstream_client.clone(); - let name = envelope.payload.name.clone(); - async move |_, _| { - while let Some(status) = status_rx.recv().await.ok() { - downstream_client.send( - proto::ExternalAgentLoadingStatusUpdated { - project_id, - name: name.clone(), - status: status.to_string(), - }, - )?; + let new_version_available_tx = + downstream_client + .clone() + .map(|(project_id, downstream_client)| { + let (new_version_available_tx, mut new_version_available_rx) = + watch::channel(None); + cx.spawn({ + let name = envelope.payload.name.clone(); + async move |_, _| { + if let Some(version) = + new_version_available_rx.recv().await.ok().flatten() + { + downstream_client.send( + proto::NewExternalAgentVersionAvailable { + project_id, + name: name.clone(), + version, + }, + )?; + } + anyhow::Ok(()) } - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - cx.spawn({ - let name = envelope.payload.name.clone(); - async move |_, _| { - if let Some(version) = - new_version_available_rx.recv().await.ok().flatten() - { - downstream_client.send( - proto::NewExternalAgentVersionAvailable { - project_id, - name: name.clone(), - version, - }, - )?; - } - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - (status_tx, new_version_available_tx) - }) - .unzip(); + }) + .detach_and_log_err(cx); + new_version_available_tx + }); let mut extra_env = HashMap::default(); if no_browser { extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned()); } anyhow::Ok(agent.get_command( extra_env, - status_tx, new_version_available_tx, &mut cx.to_async(), )) @@ -782,13 +760,11 @@ impl AgentServerStore { }; let mut previous_entries = std::mem::take(&mut this.external_agents); - let mut status_txs = HashMap::default(); let mut new_version_available_txs = HashMap::default(); let mut metadata = HashMap::default(); for (name, mut entry) in previous_entries.drain() { if let Some(agent) = entry.server.downcast_mut::() { - status_txs.insert(name.clone(), agent.status_tx.take()); new_version_available_txs .insert(name.clone(), agent.new_version_available_tx.take()); } @@ -820,7 +796,6 @@ impl AgentServerStore { project_id: *project_id, upstream_client: upstream_client.clone(), name: agent_name.clone(), - status_tx: status_txs.remove(&agent_name).flatten(), new_version_available_tx: new_version_available_txs .remove(&agent_name) .flatten(), @@ -884,22 +859,6 @@ impl AgentServerStore { }) } - async fn handle_loading_status_updated( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result<()> { - this.update(&mut cx, |this, _| { - if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) - && let Some(agent) = agent.server.downcast_mut::() - && let Some(status_tx) = &mut agent.status_tx - { - status_tx.send(envelope.payload.status.into()).ok(); - } - }); - Ok(()) - } - async fn handle_new_version_available( this: Entity, envelope: TypedEnvelope, @@ -936,7 +895,6 @@ struct RemoteExternalAgentServer { project_id: u64, upstream_client: Entity, name: ExternalAgentServerName, - status_tx: Option>, new_version_available_tx: Option>>, } @@ -944,14 +902,12 @@ impl ExternalAgentServer for RemoteExternalAgentServer { fn get_command( &mut self, extra_env: HashMap, - status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { let project_id = self.project_id; let name = self.name.to_string(); let upstream_client = self.upstream_client.downgrade(); - self.status_tx = status_tx; self.new_version_available_tx = new_version_available_tx; cx.spawn(async move |cx| { let mut response = upstream_client @@ -1005,7 +961,6 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1205,7 +1160,6 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1386,7 +1340,6 @@ impl ExternalAgentServer for LocalRegistryNpxAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1453,7 +1406,6 @@ impl ExternalAgentServer for LocalCustomAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs index f3c398a619a81ee81146de16f8e58b1093569e8a..40961cd0267db9effc897376de9531d5ceb6f463 100644 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ b/crates/project/tests/integration/ext_agent_tests.rs @@ -10,7 +10,6 @@ impl ExternalAgentServer for NoopExternalAgent { fn get_command( &mut self, _extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, ) -> Task> { diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs index eff41a99cab878336206f232450f3c1b490d1fc8..b45f76fbd6835f0cf94f8622df10c2eee3b3c9d3 100644 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ b/crates/project/tests/integration/extension_agent_tests.rs @@ -26,7 +26,6 @@ impl ExternalAgentServer for NoopExternalAgent { fn get_command( &mut self, _extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, ) -> Task> { diff --git a/crates/proto/proto/ai.proto b/crates/proto/proto/ai.proto index 428d971c536f6e830e0c056372d311dc7ed7028f..8db36153b5ef75218f0c007e113f1c2c06ded7eb 100644 --- a/crates/proto/proto/ai.proto +++ b/crates/proto/proto/ai.proto @@ -222,7 +222,7 @@ message ExternalExtensionAgentsUpdated { message ExternalAgentLoadingStatusUpdated { uint64 project_id = 1; string name = 2; - string status = 3; + reserved 3; } message NewExternalAgentVersionAvailable { diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 7f9953c8a4e746d9586b663330badb38149cfb64..0f1d1e3769c405abce5ebf55818f19e64afadc82 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2028,7 +2028,6 @@ async fn test_remote_external_agent_server( .get_command( HashMap::from_iter([("OTHER_VAR".into(), "other-val".into())]), None, - None, &mut cx.to_async(), ) }) diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 36a8d1cf085e544d38d903fe63f514539287dcc5..e6b873704ffda9d241fec002eb0fdff0af979c48 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -47,4 +47,5 @@ fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } \ No newline at end of file +workspace = { workspace = true, features = ["test-support"] } +recent_projects = { workspace = true, features = ["test-support"] } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index ceb566f4c7b22acea44faa3b7f0bf3879d28b7ec..d5cf352665a8cd59bdd6a6b601248bce4a214e3b 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -2569,15 +2569,15 @@ mod tests { let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); // Open thread A and keep it generating. - let connection_a = StubAgentConnection::new(); - open_thread_with_connection(&panel, connection_a.clone(), cx); + let connection = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection.clone(), cx); send_message(&panel, cx); let session_id_a = active_session_id(&panel, cx); save_thread_to_store(&session_id_a, &path_list, cx).await; cx.update(|_, cx| { - connection_a.send_update( + connection.send_update( session_id_a.clone(), acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), cx, @@ -2586,11 +2586,10 @@ mod tests { cx.run_until_parked(); // Open thread B (idle, default response) — thread A goes to background. - let connection_b = StubAgentConnection::new(); - connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Done".into()), )]); - open_thread_with_connection(&panel, connection_b, cx); + open_thread_with_connection(&panel, connection, cx); send_message(&panel, cx); let session_id_b = active_session_id(&panel, cx); From 074ca4cadf7390b77eb3ca6979632a6582d25e2b Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 10 Mar 2026 14:21:16 -0400 Subject: [PATCH 05/32] Enable diff stats in the git panel by default (#51215) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [X] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Enabled `diff_stats` in the git panel by default. --- assets/settings/default.json | 4 ++-- crates/settings_content/src/settings_content.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 0a824bbe93a0d68a23d934a63eb1fdab1e2f1b02..d812673d9dac997df570625be3ea07cf1cb831dc 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -920,8 +920,8 @@ }, // Whether to show the addition/deletion change count next to each file in the Git panel. // - // Default: false - "diff_stats": false, + // Default: true + "diff_stats": true, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 5a4e87c384d802f3de4c96c07f65cf163c3a6d1a..5b573a0f01dc7980abadeba5576b6e8e3553bfb4 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -622,7 +622,7 @@ pub struct GitPanelSettingsContent { /// Whether to show the addition/deletion change count next to each file in the Git panel. /// - /// Default: false + /// Default: true pub diff_stats: Option, } From e9e71431bb26ce554d4ed2024569745df6dd9047 Mon Sep 17 00:00:00 2001 From: Imamuzzaki Abu Salam Date: Wed, 11 Mar 2026 01:55:57 +0700 Subject: [PATCH 06/32] Add size to DiskState to detect file changes (#49436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This fix addresses the cross-platform root cause identified in issue #38109 where open buffers go stale or empty when external tools write files. ## The Problem The buffer's `file_updated()` method was only comparing `mtime` to determine if a buffer needed to be reloaded. This caused a race condition when external tools write files using `std::fs::write()`, which uses `O_TRUNC` and creates a brief window where the file is 0 bytes: 1. Scanner re-stats → sees 0 bytes, mtime T 2. `file_updated()` sees mtime changed → emits `ReloadNeeded` 3. Buffer reloads to empty, stamps `saved_mtime = T` 4. Tool finishes writing → file has content, but mtime is still T (or same-second granularity) 5. Scanner re-stats → mtime T matches `saved_mtime` → **no reload triggered** 6. Buffer permanently stuck empty ## The Fix Release Notes: - Add the file `size` to `DiskState::Present`, so that even when mtime stays the same, size changes (0 → N bytes) will trigger a reload. This is the same fix that was identified in the issue by @lex00. ## Changes - `crates/language/src/buffer.rs`: Add `size: u64` to `DiskState::Present`, add `size()` method - `crates/worktree/src/worktree.rs`: Pass size when constructing File and DiskState::Present - `crates/project/src/buffer_store.rs`: Pass size when constructing File - `crates/project/src/image_store.rs`: Pass size when constructing File - `crates/copilot/src/copilot.rs`: Update test mock ## Test plan - [ ] Open a file in Zed - [ ] Write to that file from an external tool (e.g., `echo "content" > file`) - [ ] Verify the buffer updates correctly without needing to reload Fixes #38109 --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Ben Kunkle Co-authored-by: Jakub Konka --- crates/copilot/src/copilot.rs | 1 + crates/language/src/buffer.rs | 16 +++++++++++++--- crates/project/src/buffer_store.rs | 5 ++++- crates/project/src/image_store.rs | 5 ++++- crates/worktree/src/worktree.rs | 10 ++++++++-- 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 3506672b2e79419a3a46cb0963af353a3a71730a..4a08cf2803aaa51a86d5dc7017c559bee1184c2e 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1779,6 +1779,7 @@ mod tests { fn disk_state(&self) -> language::DiskState { language::DiskState::Present { mtime: ::fs::MTime::from_seconds_and_nanos(100, 42), + size: 0, } } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index f92ae2419edf61aaa20643c3f87dac2f4af8bf4e..6724b5b1c2e6b666b7f0295685e40427279a0b30 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -435,7 +435,7 @@ pub enum DiskState { /// File created in Zed that has not been saved. New, /// File present on the filesystem. - Present { mtime: MTime }, + Present { mtime: MTime, size: u64 }, /// Deleted file that was previously present. Deleted, /// An old version of a file that was previously present @@ -448,7 +448,17 @@ impl DiskState { pub fn mtime(self) -> Option { match self { DiskState::New => None, - DiskState::Present { mtime } => Some(mtime), + DiskState::Present { mtime, .. } => Some(mtime), + DiskState::Deleted => None, + DiskState::Historic { .. } => None, + } + } + + /// Returns the file's size on disk in bytes. + pub fn size(self) -> Option { + match self { + DiskState::New => None, + DiskState::Present { size, .. } => Some(size), DiskState::Deleted => None, DiskState::Historic { .. } => None, } @@ -2377,7 +2387,7 @@ impl Buffer { }; match file.disk_state() { DiskState::New => false, - DiskState::Present { mtime } => match self.saved_mtime { + DiskState::Present { mtime, .. } => match self.saved_mtime { Some(saved_mtime) => { mtime.bad_is_greater_than(saved_mtime) && self.has_unsaved_edits() } diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index b9d1105ad02415699fa6a9bd1be8ec1f9c71271a..d2f05a119a1883a1ec744b40d4cdb467074d3c83 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -527,7 +527,10 @@ impl LocalBufferStore { let new_file = if let Some(entry) = snapshot_entry { File { disk_state: match entry.mtime { - Some(mtime) => DiskState::Present { mtime }, + Some(mtime) => DiskState::Present { + mtime, + size: entry.size, + }, None => old_file.disk_state, }, is_local: true, diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 654fb0344db4b7dc581234a5b446e8ac4d2b10ab..0ba9787d2e4144cb529756b15fc05ff72dab83c8 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -808,7 +808,10 @@ impl LocalImageStore { let new_file = if let Some(entry) = snapshot_entry { worktree::File { disk_state: match entry.mtime { - Some(mtime) => DiskState::Present { mtime }, + Some(mtime) => DiskState::Present { + mtime, + size: entry.size, + }, None => old_file.disk_state, }, is_local: true, diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 9e62beb3c375fb8d580be02382091cafe04d31e2..44ba4e752cff778b7918b9a29935d0f0e1ebb614 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1322,6 +1322,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, is_local: true, is_private, @@ -1378,6 +1379,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, is_local: true, is_private, @@ -1575,6 +1577,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, entry_id: None, is_local: true, @@ -3289,7 +3292,10 @@ impl File { worktree, path: entry.path.clone(), disk_state: if let Some(mtime) = entry.mtime { - DiskState::Present { mtime } + DiskState::Present { + mtime, + size: entry.size, + } } else { DiskState::New }, @@ -3318,7 +3324,7 @@ impl File { } else if proto.is_deleted { DiskState::Deleted } else if let Some(mtime) = proto.mtime.map(&Into::into) { - DiskState::Present { mtime } + DiskState::Present { mtime, size: 0 } } else { DiskState::New }; From 95aa4f274daf690c7c7048ba6c135c81af0d3347 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Tue, 10 Mar 2026 20:34:42 +0100 Subject: [PATCH 07/32] git_graph: Add select first & last actions (#50956) This PR adds support for select first & last actions, as I was missing them badly :). **Example**: https://github.com/user-attachments/assets/709037e6-544c-4891-8f48-7808d556a5a2 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- crates/git_graph/src/git_graph.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 90ccf94f5f91720972a52d85bc506d12c1a528cb..12ed44cd7ec2de0e68d56642b756e1be824e19fe 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -15,7 +15,7 @@ use gpui::{ px, uniform_list, }; use language::line_diff; -use menu::{Cancel, SelectNext, SelectPrevious}; +use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{ Project, git_store::{ @@ -1171,22 +1171,35 @@ impl GitGraph { cx.notify(); } - fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context) { + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { + self.select_entry(0, cx); + } + + fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { if let Some(selected_entry_idx) = &self.selected_entry_idx { self.select_entry(selected_entry_idx.saturating_sub(1), cx); } else { - self.select_entry(0, cx); + self.select_first(&SelectFirst, window, cx); } } fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { if let Some(selected_entry_idx) = &self.selected_entry_idx { - self.select_entry(selected_entry_idx.saturating_add(1), cx); + self.select_entry( + selected_entry_idx + .saturating_add(1) + .min(self.graph_data.commits.len().saturating_sub(1)), + cx, + ); } else { self.select_prev(&SelectPrevious, window, cx); } } + fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx); + } + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { self.open_selected_commit_view(window, cx); } @@ -2260,8 +2273,10 @@ impl Render for GitGraph { this.open_selected_commit_view(window, cx); })) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .child(content) .children(self.context_menu.as_ref().map(|(menu, position, _)| { From 7132b67962e2827f73f8486793ab5d5dcee53862 Mon Sep 17 00:00:00 2001 From: Justin Su Date: Tue, 10 Mar 2026 15:41:24 -0400 Subject: [PATCH 08/32] Normalize `line_comments` strings to have a trailing space (#51033) I did a search for `/^line_comments = .*[^\s\[]"/` to identify these 3 languages: - Git Commit - Go Mod - Go Work that don't add/remove a trailing space for inline comments. I couldn't find any indication that the absence of the trailing space is due to any peculiarity of these languages. --- For Git Commit, I should note that (strictly speaking) the comment character is a single `#` without a trailing space, as Git removes any line starting with the default comment character (`#`) (see https://git-scm.com/docs/git-config#Documentation/git-config.txt-corecommentChar). But I believe this change only affects whether `editor::ToggleComments` adds/removes the space, and not how the file is syntax highlighted. So for aesthetics and consistency, it should be better to add/remove the trailing space. --- Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Add/remove a space when toggling inline comments in Git Commit and Go Mod/Work languages --- crates/languages/src/gitcommit/config.toml | 2 +- crates/languages/src/gomod/config.toml | 2 +- crates/languages/src/gowork/config.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/languages/src/gitcommit/config.toml b/crates/languages/src/gitcommit/config.toml index c2421ce00613e5848aacab5d1230ab839c8b1388..83cd6f550e3f18c5d8cb61efa4d632ece6c1ad4d 100644 --- a/crates/languages/src/gitcommit/config.toml +++ b/crates/languages/src/gitcommit/config.toml @@ -7,7 +7,7 @@ path_suffixes = [ "NOTES_EDITMSG", "EDIT_DESCRIPTION", ] -line_comments = ["#"] +line_comments = ["# "] brackets = [ { start = "(", end = ")", close = true, newline = false }, { start = "`", end = "`", close = true, newline = false }, diff --git a/crates/languages/src/gomod/config.toml b/crates/languages/src/gomod/config.toml index e70c9358bfc6f467b69897fa6d20dd9ae0082f9a..d151db961106591c07850034f669304db7edb650 100644 --- a/crates/languages/src/gomod/config.toml +++ b/crates/languages/src/gomod/config.toml @@ -2,7 +2,7 @@ name = "Go Mod" code_fence_block_name = "go.mod" grammar = "gomod" path_suffixes = ["mod"] -line_comments = ["//"] +line_comments = ["// "] autoclose_before = ")" brackets = [ { start = "(", end = ")", close = true, newline = true} diff --git a/crates/languages/src/gowork/config.toml b/crates/languages/src/gowork/config.toml index 68beb073ab64df4761bf3f87a88f28a0608656f7..90e62f0cf102306b258e9efd56bb9ae9838f0f27 100644 --- a/crates/languages/src/gowork/config.toml +++ b/crates/languages/src/gowork/config.toml @@ -2,7 +2,7 @@ name = "Go Work" code_fence_block_name = "gowork" grammar = "gowork" path_suffixes = ["work"] -line_comments = ["//"] +line_comments = ["// "] autoclose_before = ")" brackets = [ { start = "(", end = ")", close = true, newline = true} From 9ddaee0ac2e54d27d543aa0e74001851da157944 Mon Sep 17 00:00:00 2001 From: Danny Milosavljevic Date: Tue, 10 Mar 2026 21:25:12 +0100 Subject: [PATCH 09/32] sqlez: Open named in-memory databases as SQLite URIs (#50967) Closes #51011 Before you mark this PR as ready for review, make sure that you have: - [X] Added a solid test coverage and/or screenshots from doing manual testing - [X] Done a self-review taking into account security and performance aspects - [X] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/sqlez/src/connection.rs | 103 +++++++++++++++++++-- crates/sqlez/src/thread_safe_connection.rs | 101 ++++++++++++-------- 2 files changed, 155 insertions(+), 49 deletions(-) diff --git a/crates/sqlez/src/connection.rs b/crates/sqlez/src/connection.rs index 53f0d4e2614f340cc0563d5cd9374bdc3626d9bb..fb3194aaf428f9848b858b104e94de60765d6f9a 100644 --- a/crates/sqlez/src/connection.rs +++ b/crates/sqlez/src/connection.rs @@ -18,7 +18,7 @@ pub struct Connection { unsafe impl Send for Connection {} impl Connection { - pub(crate) fn open(uri: &str, persistent: bool) -> Result { + fn open_with_flags(uri: &str, persistent: bool, flags: i32) -> Result { let mut connection = Self { sqlite3: ptr::null_mut(), persistent, @@ -26,7 +26,6 @@ impl Connection { _sqlite: PhantomData, }; - let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE; unsafe { sqlite3_open_v2( CString::new(uri)?.as_ptr(), @@ -44,6 +43,14 @@ impl Connection { Ok(connection) } + pub(crate) fn open(uri: &str, persistent: bool) -> Result { + Self::open_with_flags( + uri, + persistent, + SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE, + ) + } + /// Attempts to open the database at uri. If it fails, a shared memory db will be opened /// instead. pub fn open_file(uri: &str) -> Self { @@ -51,13 +58,17 @@ impl Connection { } pub fn open_memory(uri: Option<&str>) -> Self { - let in_memory_path = if let Some(uri) = uri { - format!("file:{}?mode=memory&cache=shared", uri) + if let Some(uri) = uri { + let in_memory_path = format!("file:{}?mode=memory&cache=shared", uri); + return Self::open_with_flags( + &in_memory_path, + false, + SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE | SQLITE_OPEN_URI, + ) + .expect("Could not create fallback in memory db"); } else { - ":memory:".to_string() - }; - - Self::open(&in_memory_path, false).expect("Could not create fallback in memory db") + Self::open(":memory:", false).expect("Could not create fallback in memory db") + } } pub fn persistent(&self) -> bool { @@ -265,9 +276,50 @@ impl Drop for Connection { mod test { use anyhow::Result; use indoc::indoc; + use std::{ + fs, + sync::atomic::{AtomicUsize, Ordering}, + }; use crate::connection::Connection; + static NEXT_NAMED_MEMORY_DB_ID: AtomicUsize = AtomicUsize::new(0); + + fn unique_named_memory_db(prefix: &str) -> String { + format!( + "{prefix}_{}_{}", + std::process::id(), + NEXT_NAMED_MEMORY_DB_ID.fetch_add(1, Ordering::Relaxed) + ) + } + + fn literal_named_memory_paths(name: &str) -> [String; 3] { + let main = format!("file:{name}?mode=memory&cache=shared"); + [main.clone(), format!("{main}-wal"), format!("{main}-shm")] + } + + struct NamedMemoryPathGuard { + paths: [String; 3], + } + + impl NamedMemoryPathGuard { + fn new(name: &str) -> Self { + let paths = literal_named_memory_paths(name); + for path in &paths { + let _ = fs::remove_file(path); + } + Self { paths } + } + } + + impl Drop for NamedMemoryPathGuard { + fn drop(&mut self) { + for path in &self.paths { + let _ = fs::remove_file(path); + } + } + } + #[test] fn string_round_trips() -> Result<()> { let connection = Connection::open_memory(Some("string_round_trips")); @@ -382,6 +434,41 @@ mod test { assert_eq!(read_blobs, vec![blob]); } + #[test] + fn named_memory_connections_do_not_create_literal_backing_files() { + let name = unique_named_memory_db("named_memory_connections_do_not_create_backing_files"); + let guard = NamedMemoryPathGuard::new(&name); + + let connection1 = Connection::open_memory(Some(&name)); + connection1 + .exec(indoc! {" + CREATE TABLE shared ( + value INTEGER + )"}) + .unwrap()() + .unwrap(); + connection1 + .exec("INSERT INTO shared (value) VALUES (7)") + .unwrap()() + .unwrap(); + + let connection2 = Connection::open_memory(Some(&name)); + assert_eq!( + connection2 + .select_row::("SELECT value FROM shared") + .unwrap()() + .unwrap(), + Some(7) + ); + + for path in &guard.paths { + assert!( + fs::metadata(path).is_err(), + "named in-memory database unexpectedly created backing file {path}" + ); + } + } + #[test] fn multi_step_statement_works() { let connection = Connection::open_memory(Some("multi_step_statement_works")); diff --git a/crates/sqlez/src/thread_safe_connection.rs b/crates/sqlez/src/thread_safe_connection.rs index 966f14a9c2f244780da7190aebac88e95c7ac068..7b3630cdf65f900469e3d7544f3bd75b33250625 100644 --- a/crates/sqlez/src/thread_safe_connection.rs +++ b/crates/sqlez/src/thread_safe_connection.rs @@ -7,12 +7,15 @@ use std::{ ops::Deref, sync::{Arc, LazyLock}, thread, + time::Duration, }; use thread_local::ThreadLocal; use crate::{connection::Connection, domain::Migrator, util::UnboundedSyncSender}; const MIGRATION_RETRIES: usize = 10; +const CONNECTION_INITIALIZE_RETRIES: usize = 50; +const CONNECTION_INITIALIZE_RETRY_DELAY: Duration = Duration::from_millis(1); type QueuedWrite = Box; type WriteQueue = Box; @@ -197,21 +200,54 @@ impl ThreadSafeConnection { Self::open_shared_memory(uri) }; + if let Some(initialize_query) = connection_initialize_query { + let mut last_error = None; + let initialized = (0..CONNECTION_INITIALIZE_RETRIES).any(|attempt| { + match connection + .exec(initialize_query) + .and_then(|mut statement| statement()) + { + Ok(()) => true, + Err(err) + if is_schema_lock_error(&err) + && attempt + 1 < CONNECTION_INITIALIZE_RETRIES => + { + last_error = Some(err); + thread::sleep(CONNECTION_INITIALIZE_RETRY_DELAY); + false + } + Err(err) => { + panic!( + "Initialize query failed to execute: {}\n\nCaused by:\n{err:#}", + initialize_query + ) + } + } + }); + + if !initialized { + let err = last_error + .expect("connection initialization retries should record the last error"); + panic!( + "Initialize query failed to execute after retries: {}\n\nCaused by:\n{err:#}", + initialize_query + ); + } + } + // Disallow writes on the connection. The only writes allowed for thread safe connections // are from the background thread that can serialize them. *connection.write.get_mut() = false; - if let Some(initialize_query) = connection_initialize_query { - connection.exec(initialize_query).unwrap_or_else(|_| { - panic!("Initialize query failed to execute: {}", initialize_query) - })() - .unwrap() - } - connection } } +fn is_schema_lock_error(err: &anyhow::Error) -> bool { + let message = format!("{err:#}"); + message.contains("database schema is locked") || message.contains("database is locked") +} + impl ThreadSafeConnection { /// Special constructor for ThreadSafeConnection which disallows db initialization and migrations. /// This allows construction to be infallible and not write to the db. @@ -282,7 +318,7 @@ mod test { use indoc::indoc; use std::ops::Deref; - use std::thread; + use std::{thread, time::Duration}; use crate::{domain::Domain, thread_safe_connection::ThreadSafeConnection}; @@ -318,38 +354,21 @@ mod test { } #[test] - #[should_panic] - fn wild_zed_lost_failure() { - enum TestWorkspace {} - impl Domain for TestWorkspace { - const NAME: &str = "workspace"; - - const MIGRATIONS: &[&str] = &[" - CREATE TABLE workspaces( - workspace_id INTEGER PRIMARY KEY, - dock_visible INTEGER, -- Boolean - dock_anchor TEXT, -- Enum: 'Bottom' / 'Right' / 'Expanded' - dock_pane INTEGER, -- NULL indicates that we don't have a dock pane yet - timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - FOREIGN KEY(dock_pane) REFERENCES panes(pane_id), - FOREIGN KEY(active_pane) REFERENCES panes(pane_id) - ) STRICT; - - CREATE TABLE panes( - pane_id INTEGER PRIMARY KEY, - workspace_id INTEGER NOT NULL, - active INTEGER NOT NULL, -- Boolean - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ON UPDATE CASCADE - ) STRICT; - "]; - } - - let builder = - ThreadSafeConnection::builder::("wild_zed_lost_failure", false) - .with_connection_initialize_query("PRAGMA FOREIGN_KEYS=true"); - - smol::block_on(builder.build()).unwrap(); + fn connection_initialize_query_retries_transient_schema_lock() { + let name = "connection_initialize_query_retries_transient_schema_lock"; + let locking_connection = crate::connection::Connection::open_memory(Some(name)); + locking_connection.exec("BEGIN IMMEDIATE").unwrap()().unwrap(); + locking_connection + .exec("CREATE TABLE test(col TEXT)") + .unwrap()() + .unwrap(); + + let releaser = thread::spawn(move || { + thread::sleep(Duration::from_millis(10)); + locking_connection.exec("ROLLBACK").unwrap()().unwrap(); + }); + + ThreadSafeConnection::create_connection(false, name, Some("PRAGMA FOREIGN_KEYS=true")); + releaser.join().unwrap(); } } From 38fa78cec7afbe24259ba30b5b499a90b348d378 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Tue, 10 Mar 2026 22:41:43 +0100 Subject: [PATCH 10/32] ci: Update workflows/scripts for deploying collab to use clang (#51224) Release Notes: - N/A --- .github/workflows/deploy_collab.yml | 6 ++++++ Dockerfile-collab | 6 +++++- tooling/xtask/src/tasks/workflows/deploy_collab.rs | 10 +++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 89fb6980b65f2d09a6571f140ab016a710be230f..0d98438c9e3029f85cc37cb4e57f6c9e24df43b0 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -12,6 +12,9 @@ jobs: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') name: Check formatting and Clippy lints runs-on: namespace-profile-16x32-ubuntu-2204 + env: + CC: clang + CXX: clang++ steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 @@ -42,6 +45,9 @@ jobs: - style name: Run tests runs-on: namespace-profile-16x32-ubuntu-2204 + env: + CC: clang + CXX: clang++ steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 diff --git a/Dockerfile-collab b/Dockerfile-collab index 63359334906b58c560c0ed6acc6378259ccbd5c5..50af874200a6ef3bc3c882b7d08257ec41f944de 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -14,8 +14,12 @@ ARG GITHUB_SHA ENV GITHUB_SHA=$GITHUB_SHA # Also add `cmake`, since we need it to build `wasmtime`. +# clang is needed because `webrtc-sys` uses Clang-specific compiler flags. RUN apt-get update; \ - apt-get install -y --no-install-recommends cmake + apt-get install -y --no-install-recommends cmake clang + +ENV CC=clang +ENV CXX=clang++ RUN --mount=type=cache,target=./script/node_modules \ --mount=type=cache,target=/usr/local/cargo/registry \ diff --git a/tooling/xtask/src/tasks/workflows/deploy_collab.rs b/tooling/xtask/src/tasks/workflows/deploy_collab.rs index 300680f95b880e9adb14dffd2572d80cb08fd63c..a13e5684f615e1c219e131f7308f6e021e89ac9f 100644 --- a/tooling/xtask/src/tasks/workflows/deploy_collab.rs +++ b/tooling/xtask/src/tasks/workflows/deploy_collab.rs @@ -3,7 +3,7 @@ use indoc::indoc; use crate::tasks::workflows::runners::{self, Platform}; use crate::tasks::workflows::steps::{ - self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, + self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, use_clang, }; use crate::tasks::workflows::vars; @@ -23,7 +23,7 @@ pub(crate) fn deploy_collab() -> Workflow { } fn style() -> NamedJob { - named::job( + named::job(use_clang( dependant_job(&[]) .name("Check formatting and Clippy lints") .with_repository_owner_guard() @@ -34,7 +34,7 @@ fn style() -> NamedJob { .map(steps::install_linux_dependencies) .add_step(steps::cargo_fmt()) .add_step(steps::clippy(Platform::Linux)), - ) + )) } fn tests(deps: &[&NamedJob]) -> NamedJob { @@ -42,7 +42,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob { named::bash("cargo nextest run --package collab --no-fail-fast") } - named::job( + named::job(use_clang( dependant_job(deps) .name("Run tests") .runs_on(runners::LINUX_XL) @@ -65,7 +65,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob { .add_step(steps::cargo_install_nextest()) .add_step(steps::clear_target_dir_if_large(Platform::Linux)) .add_step(run_collab_tests()), - ) + )) } fn publish(deps: &[&NamedJob]) -> NamedJob { From 51ba321b4ac95ab4ff3c58c99eb4e849da622f90 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 10 Mar 2026 18:36:49 -0400 Subject: [PATCH 11/32] collab: Update test database schema (#51233) This PR updates the database schema for Collab tests, along with a warning to not modify the file by hand. Release Notes: - N/A --- .../migrations/20251208000000_test_schema.sql | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 0f4e4f2d2e3925ea1e4d2b964c5e4f159f393b4f..53543a23f710e49084a7b1127e7b743df6ef97c8 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -1,3 +1,6 @@ +-- This file is auto-generated. Do not modify it by hand. +-- To regenerate, run `cargo xtask db dump-schema app --collab` from the Cloud repository. + CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; CREATE TABLE public.breakpoints ( @@ -315,10 +318,10 @@ CREATE TABLE public.project_repository_statuses ( status_kind integer NOT NULL, first_status integer, second_status integer, - lines_added integer, - lines_deleted integer, scan_id bigint NOT NULL, - is_deleted boolean NOT NULL + is_deleted boolean NOT NULL, + lines_added integer, + lines_deleted integer ); CREATE TABLE public.projects ( @@ -706,6 +709,8 @@ CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name CREATE INDEX trigram_index_users_on_github_login ON public.users USING gin (github_login public.gin_trgm_ops); +CREATE INDEX trigram_index_users_on_name ON public.users USING gin (name public.gin_trgm_ops); + CREATE UNIQUE INDEX uix_channels_parent_path_name ON public.channels USING btree (parent_path, name) WHERE ((parent_path IS NOT NULL) AND (parent_path <> ''::text)); CREATE UNIQUE INDEX uix_users_on_github_user_id ON public.users USING btree (github_user_id); @@ -753,7 +758,7 @@ ALTER TABLE ONLY public.contacts ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES public.users(id) ON DELETE CASCADE; ALTER TABLE ONLY public.contributors - ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; ALTER TABLE ONLY public.extension_versions ADD CONSTRAINT extension_versions_extension_id_fkey FOREIGN KEY (extension_id) REFERENCES public.extensions(id); From 7bf73098a7d90f2cc3ec0293bc052a81888875e9 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 10 Mar 2026 18:53:38 -0400 Subject: [PATCH 12/32] danger: Add a check for changing Collab database schemas (#51234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a Danger check to remind contributors that any database changes for Collab need to be done via a migration in the Cloud repo: Screenshot 2026-03-10 at 6 39 21 PM Release Notes: - N/A --- script/danger/dangerfile.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/script/danger/dangerfile.ts b/script/danger/dangerfile.ts index b604a42e45ac7d276a1f278bd2e9727daa98c375..c1ca883f3e910f434f686985d2c94df22986a029 100644 --- a/script/danger/dangerfile.ts +++ b/script/danger/dangerfile.ts @@ -61,6 +61,25 @@ if (includesIssueUrl) { ); } +const MIGRATION_SCHEMA_FILES = [ + "crates/collab/migrations/20251208000000_test_schema.sql", + "crates/collab/migrations.sqlite/20221109000000_test_schema.sql", +]; + +const modifiedSchemaFiles = danger.git.modified_files.filter((file) => + MIGRATION_SCHEMA_FILES.some((schemaFilePath) => file.endsWith(schemaFilePath)), +); + +if (modifiedSchemaFiles.length > 0) { + warn( + [ + "This PR modifies database schema files.", + "", + "If you are making database changes, a migration needs to be added in the Cloud repository.", + ].join("\n"), + ); +} + const FIXTURE_CHANGE_ATTESTATION = "Changes to test fixtures are intentional and necessary."; const FIXTURES_PATHS = ["crates/assistant_tools/src/edit_agent/evals/fixtures"]; From b13a8e8fe1899ad27e03a5a4ebdd5bed50a50128 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:05:32 -0300 Subject: [PATCH 13/32] agent_ui: Enable mentioning branch diff with main (#51235) As a follow up to the possibility of sending the branch diff to an agent review, this PR enables directly @-mentioning the content of your diff with main to the agent. Here's a quick video of it: https://github.com/user-attachments/assets/f27b7287-c9b9-4ccf-875e-4ac6ce4cd8ad Release Notes: - Agent: Enabled mentioning the branch diff with main. --- crates/agent_ui/src/completion_provider.rs | 134 ++++++++++++++++++++- crates/agent_ui/src/mention_set.rs | 45 ++++++- crates/agent_ui/src/message_editor.rs | 1 + 3 files changed, 175 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 40ad7bc729269d5dae3364ecf3e0de6e5ee5b0ec..d8c45755413ffb14433e3eeb4309e869de195a75 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -64,6 +64,7 @@ pub(crate) enum PromptContextType { Thread, Rules, Diagnostics, + BranchDiff, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -102,6 +103,7 @@ impl TryFrom<&str> for PromptContextType { "thread" => Ok(Self::Thread), "rule" => Ok(Self::Rules), "diagnostics" => Ok(Self::Diagnostics), + "diff" => Ok(Self::BranchDiff), _ => Err(format!("Invalid context picker mode: {}", value)), } } @@ -116,6 +118,7 @@ impl PromptContextType { Self::Thread => "thread", Self::Rules => "rule", Self::Diagnostics => "diagnostics", + Self::BranchDiff => "branch diff", } } @@ -127,6 +130,7 @@ impl PromptContextType { Self::Thread => "Threads", Self::Rules => "Rules", Self::Diagnostics => "Diagnostics", + Self::BranchDiff => "Branch Diff", } } @@ -138,6 +142,7 @@ impl PromptContextType { Self::Thread => IconName::Thread, Self::Rules => IconName::Reader, Self::Diagnostics => IconName::Warning, + Self::BranchDiff => IconName::GitBranch, } } } @@ -150,6 +155,12 @@ pub(crate) enum Match { Fetch(SharedString), Rules(RulesContextEntry), Entry(EntryMatch), + BranchDiff(BranchDiffMatch), +} + +#[derive(Debug, Clone)] +pub struct BranchDiffMatch { + pub base_ref: SharedString, } impl Match { @@ -162,6 +173,7 @@ impl Match { Match::Symbol(_) => 1., Match::Rules(_) => 1., Match::Fetch(_) => 1., + Match::BranchDiff(_) => 1., } } } @@ -781,6 +793,47 @@ impl PromptCompletionProvider { } } + fn build_branch_diff_completion( + base_ref: SharedString, + source_range: Range, + source: Arc, + editor: WeakEntity, + mention_set: WeakEntity, + workspace: Entity, + cx: &mut App, + ) -> Completion { + let uri = MentionUri::GitDiff { + base_ref: base_ref.to_string(), + }; + let crease_text: SharedString = format!("Branch Diff (vs {})", base_ref).into(); + let display_text = format!("@{}", crease_text); + let new_text = format!("[{}]({}) ", display_text, uri.to_uri()); + let new_text_len = new_text.len(); + let icon_path = uri.icon_path(cx); + + Completion { + replace_range: source_range.clone(), + new_text, + label: CodeLabel::plain(crease_text.to_string(), None), + documentation: None, + source: project::CompletionSource::Custom, + icon_path: Some(icon_path), + match_start: None, + snippet_deduplication_key: None, + insert_text_mode: None, + confirm: Some(confirm_completion_callback( + crease_text, + source_range.start, + new_text_len - 1, + uri, + source, + editor, + mention_set, + workspace, + )), + } + } + fn search_slash_commands(&self, query: String, cx: &mut App) -> Task> { let commands = self.source.available_commands(cx); if commands.is_empty() { @@ -812,6 +865,27 @@ impl PromptCompletionProvider { }) } + fn fetch_branch_diff_match( + &self, + workspace: &Entity, + cx: &mut App, + ) -> Option>> { + let project = workspace.read(cx).project().clone(); + let repo = project.read(cx).active_repository(cx)?; + + let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false)); + + Some(cx.spawn(async move |_cx| { + let base_ref = default_branch_receiver + .await + .ok() + .and_then(|r| r.ok()) + .flatten()?; + + Some(BranchDiffMatch { base_ref }) + })) + } + fn search_mentions( &self, mode: Option, @@ -892,6 +966,8 @@ impl PromptCompletionProvider { Some(PromptContextType::Diagnostics) => Task::ready(Vec::new()), + Some(PromptContextType::BranchDiff) => Task::ready(Vec::new()), + None if query.is_empty() => { let recent_task = self.recent_context_picker_entries(&workspace, cx); let entries = self @@ -905,9 +981,25 @@ impl PromptCompletionProvider { }) .collect::>(); + let branch_diff_task = if self + .source + .supports_context(PromptContextType::BranchDiff, cx) + { + self.fetch_branch_diff_match(&workspace, cx) + } else { + None + }; + cx.spawn(async move |_cx| { let mut matches = recent_task.await; matches.extend(entries); + + if let Some(branch_diff_task) = branch_diff_task { + if let Some(branch_diff_match) = branch_diff_task.await { + matches.push(Match::BranchDiff(branch_diff_match)); + } + } + matches }) } @@ -924,7 +1016,16 @@ impl PromptCompletionProvider { .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword())) .collect::>(); - cx.background_spawn(async move { + let branch_diff_task = if self + .source + .supports_context(PromptContextType::BranchDiff, cx) + { + self.fetch_branch_diff_match(&workspace, cx) + } else { + None + }; + + cx.spawn(async move |cx| { let mut matches = search_files_task .await .into_iter() @@ -949,6 +1050,26 @@ impl PromptCompletionProvider { }) })); + if let Some(branch_diff_task) = branch_diff_task { + let branch_diff_keyword = PromptContextType::BranchDiff.keyword(); + let branch_diff_matches = fuzzy::match_strings( + &[StringMatchCandidate::new(0, branch_diff_keyword)], + &query, + false, + true, + 1, + &Arc::new(AtomicBool::default()), + cx.background_executor().clone(), + ) + .await; + + if !branch_diff_matches.is_empty() { + if let Some(branch_diff_match) = branch_diff_task.await { + matches.push(Match::BranchDiff(branch_diff_match)); + } + } + } + matches.sort_by(|a, b| { b.score() .partial_cmp(&a.score()) @@ -1364,6 +1485,17 @@ impl CompletionProvider for PromptCompletio cx, ) } + Match::BranchDiff(branch_diff) => { + Some(Self::build_branch_diff_completion( + branch_diff.base_ref, + source_range.clone(), + source.clone(), + editor.clone(), + mention_set.clone(), + workspace.clone(), + cx, + )) + } }) .collect::>() }); diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index e072037f1758e00e648dc46c7ee70599c4363eef..1cb22af6a3fd15df5eeedc5018deaeff77a1dbff 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -147,10 +147,12 @@ impl MentionSet { include_errors, include_warnings, } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx), + MentionUri::GitDiff { base_ref } => { + self.confirm_mention_for_git_diff(base_ref.into(), cx) + } MentionUri::PastedImage | MentionUri::Selection { .. } | MentionUri::TerminalSelection { .. } - | MentionUri::GitDiff { .. } | MentionUri::MergeConflict { .. } => { Task::ready(Err(anyhow!("Unsupported mention URI type for paste"))) } @@ -298,9 +300,8 @@ impl MentionSet { debug_panic!("unexpected terminal URI"); Task::ready(Err(anyhow!("unexpected terminal URI"))) } - MentionUri::GitDiff { .. } => { - debug_panic!("unexpected git diff URI"); - Task::ready(Err(anyhow!("unexpected git diff URI"))) + MentionUri::GitDiff { base_ref } => { + self.confirm_mention_for_git_diff(base_ref.into(), cx) } MentionUri::MergeConflict { .. } => { debug_panic!("unexpected merge conflict URI"); @@ -602,6 +603,42 @@ impl MentionSet { }) }) } + + fn confirm_mention_for_git_diff( + &self, + base_ref: SharedString, + cx: &mut Context, + ) -> Task> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project not found"))); + }; + + let Some(repo) = project.read(cx).active_repository(cx) else { + return Task::ready(Err(anyhow!("no active repository"))); + }; + + let diff_receiver = repo.update(cx, |repo, cx| { + repo.diff( + git::repository::DiffType::MergeBase { base_ref: base_ref }, + cx, + ) + }); + + cx.spawn(async move |_, _| { + let diff_text = diff_receiver.await??; + if diff_text.is_empty() { + Ok(Mention::Text { + content: "No changes found in branch diff.".into(), + tracked_buffers: Vec::new(), + }) + } else { + Ok(Mention::Text { + content: diff_text, + tracked_buffers: Vec::new(), + }) + } + }) + } } #[cfg(test)] diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 933e24e83c0450dcbdde27d49abebb7fda2fa119..6c2628f9d37efd0531d5663ac4b1d27d9ae5ae0f 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -80,6 +80,7 @@ impl PromptCompletionProviderDelegate for Entity { PromptContextType::Diagnostics, PromptContextType::Fetch, PromptContextType::Rules, + PromptContextType::BranchDiff, ]); } supported From e7a659964e1fb5cb386e7321e299fe0d2ce7e806 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:12:26 -0300 Subject: [PATCH 14/32] ui: Fix end_hover gradient overlay in `ListItem` (#51237) This PR adds a bool method to the `ListItem` that allows to turn on the gradient overlay in the `end_hover_slot`. Places that are not the sidebar, at least at the moment, don't need it. And with the previous code, they were getting it, which felt wrong. Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 20 ++++---------------- crates/ui/src/components/list/list_item.rs | 13 ++++++++++++- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index d5cf352665a8cd59bdd6a6b601248bce4a214e3b..dd1dcab9ee7b5c6de25630b9f0b8fcebcdad7cb2 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -19,9 +19,8 @@ use std::mem; use theme::{ActiveTheme, ThemeSettings}; use ui::utils::TRAFFIC_LIGHT_PADDING; use ui::{ - AgentThreadStatus, ButtonStyle, GradientFade, HighlightedLabel, IconButtonShape, KeyBinding, - ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, - prelude::*, + AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, + PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::path_list::PathList; use workspace::{ @@ -795,17 +794,6 @@ impl Sidebar { .into_any_element() }; - let color = cx.theme().colors(); - let base_bg = if is_active_workspace { - color.ghost_element_selected - } else { - color.panel_background - }; - let gradient_overlay = - GradientFade::new(base_bg, color.element_hover, color.element_active) - .width(px(48.0)) - .group_name(group_name.clone()); - ListItem::new(id) .group_name(group_name) .toggle_state(is_active_workspace) @@ -822,9 +810,9 @@ impl Sidebar { .size(IconSize::Small) .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))), ) - .child(label) - .child(gradient_overlay), + .child(label), ) + .end_hover_gradient_overlay(true) .end_hover_slot( h_flex() .when(workspace_count > 1, |this| { diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index dc2fc76a06c29c72457d385effd06ea71e5f9625..01e88e1fe666fa2038b05af055a0e02b195e9bac 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -31,6 +31,9 @@ pub struct ListItem { /// A slot for content that appears on hover after the children /// It will obscure the `end_slot` when visible. end_hover_slot: Option, + /// When true, renders a gradient fade overlay before the `end_hover_slot` + /// to smoothly truncate overflowing content. + end_hover_gradient_overlay: bool, toggle: Option, inset: bool, on_click: Option>, @@ -60,6 +63,7 @@ impl ListItem { start_slot: None, end_slot: None, end_hover_slot: None, + end_hover_gradient_overlay: false, toggle: None, inset: false, on_click: None, @@ -166,6 +170,11 @@ impl ListItem { self } + pub fn end_hover_gradient_overlay(mut self, show: bool) -> Self { + self.end_hover_gradient_overlay = show; + self + } + pub fn outlined(mut self) -> Self { self.outlined = true; self @@ -362,7 +371,9 @@ impl RenderOnce for ListItem { .right(DynamicSpacing::Base06.rems(cx)) .top_0() .visible_on_hover("list_item") - .child(end_hover_gradient_overlay) + .when(self.end_hover_gradient_overlay, |this| { + this.child(end_hover_gradient_overlay) + }) .child(end_hover_slot), ) }), From 86a757237ec4f455911738428d36e462ca9fdabf Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:30:57 +0100 Subject: [PATCH 15/32] ui: Add close and confirm button to breakpoint edit prompt block (#51239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes a problem with our breakpoint prompt edit component where there was no way through the mouse to close/confirm the edit a user was making. ### Before image ### After Screenshot 2026-03-11 at 12 16 38 AM Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- crates/editor/src/editor.rs | 58 +++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 28c200c22ab01f6e691ea52d6463c9d8be530e8c..aabf16d2b64846388b6b1c0903e280e9f465a41d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -209,6 +209,7 @@ use theme::{ use ui::{ Avatar, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide, + utils::WithRemSize, }; use ui_input::ErasedEditor; use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; @@ -29064,12 +29065,41 @@ impl BreakpointPromptEditor { }, ) } + + fn render_close_button(&self, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.prompt.focus_handle(cx); + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .shape(IconButtonShape::Square) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Cancel", &menu::Cancel, &focus_handle, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel(&menu::Cancel, window, cx); + })) + } + + fn render_confirm_button(&self, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.prompt.focus_handle(cx); + IconButton::new("confirm", IconName::Return) + .icon_color(Color::Muted) + .shape(IconButtonShape::Square) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Confirm", &menu::Confirm, &focus_handle, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.confirm(&menu::Confirm, window, cx); + })) + } } impl Render for BreakpointPromptEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); let editor_margins = *self.editor_margins.lock(); let gutter_dimensions = editor_margins.gutter; + let left_gutter_width = gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0); + let right_padding = editor_margins.right + px(9.); h_flex() .key_context("Editor") .bg(cx.theme().colors().editor_background) @@ -29077,10 +29107,34 @@ impl Render for BreakpointPromptEditor { .border_color(cx.theme().status().info_border) .size_full() .py(window.line_height() / 2.5) + .pr(right_padding) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::cancel)) - .child(h_flex().w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))) - .child(div().flex_1().child(self.render_prompt_editor(cx))) + .child( + WithRemSize::new(ui_font_size) + .h_full() + .w(left_gutter_width) + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .justify_center() + .gap_1() + .child(self.render_close_button(cx)), + ) + .child( + h_flex() + .w_full() + .justify_between() + .child(div().flex_1().child(self.render_prompt_editor(cx))) + .child( + WithRemSize::new(ui_font_size) + .flex() + .flex_row() + .items_center() + .child(self.render_confirm_button(cx)), + ), + ) } } From f0e301cea0b86bbb057f526bf12d672b8b3e958f Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 10 Mar 2026 21:08:39 -0600 Subject: [PATCH 16/32] Redact string panics (#51248) String panics are a non-trivial percentage of the crashes we see at Zed, and doubly unfortunately they may incidentally include the contents of a user's buffer. Although this hasn't happened yet (to my knowledge), I don't want to be in the position of having received sensitive information this way. See also https://github.com/rust-lang/rust/pull/153677 Release Notes: - N/A --- Cargo.lock | 1 - crates/crashes/src/crashes.rs | 28 ++++++++++- crates/feature_flags/Cargo.toml | 1 - crates/feature_flags/src/feature_flags.rs | 61 +---------------------- crates/zed/src/zed.rs | 33 ++++++------ 5 files changed, 46 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9b048468cbc4f52b86b1cd0f1b0a9d3d0f4d9e0..dfd8a74acba5056e468a72d8cd105c0f2cfd156a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6212,7 +6212,6 @@ dependencies = [ name = "feature_flags" version = "0.1.0" dependencies = [ - "futures 0.3.31", "gpui", ] diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index 60af963ee5520addedcfe9abdf41941e77922867..9f18088b0ec2e709ff420b8e107e61dd7424e643 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -350,8 +350,34 @@ impl minidumper::ServerHandler for CrashServer { } } +/// Rust's string-slicing panics embed the user's string content in the message, +/// e.g. "byte index 4 is out of bounds of `a`". Strip that suffix so we +/// don't upload arbitrary user text in crash reports. +fn strip_user_string_from_panic(message: &str) -> String { + const STRING_PANIC_PREFIXES: &[&str] = &[ + // Older rustc (pre-1.95): + "byte index ", + "begin <= end (", + // Newer rustc (1.95+): + // https://github.com/rust-lang/rust/pull/145024 + "start byte index ", + "end byte index ", + "begin > end (", + ]; + + if (message.ends_with('`') || message.ends_with("`[...]")) + && STRING_PANIC_PREFIXES + .iter() + .any(|prefix| message.starts_with(prefix)) + && let Some(open) = message.find('`') + { + return format!("{} ``", &message[..open]); + } + message.to_owned() +} + pub fn panic_hook(info: &PanicHookInfo) { - let message = info.payload_as_str().unwrap_or("Box").to_owned(); + let message = strip_user_string_from_panic(info.payload_as_str().unwrap_or("Box")); let span = info .location() diff --git a/crates/feature_flags/Cargo.toml b/crates/feature_flags/Cargo.toml index a25ca1629a539a87a7356f0419ef074e9546bc52..960834211ff18980675b236cd0cc2893d563d668 100644 --- a/crates/feature_flags/Cargo.toml +++ b/crates/feature_flags/Cargo.toml @@ -12,5 +12,4 @@ workspace = true path = "src/feature_flags.rs" [dependencies] -futures.workspace = true gpui.workspace = true diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 1d1929ed4cf89abfc5304fa111dfc7ee523d5dd8..5b8af1180aae812ed1475810acc1920a8ec708f1 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -3,12 +3,8 @@ mod flags; use std::cell::RefCell; use std::rc::Rc; use std::sync::LazyLock; -use std::time::Duration; -use std::{future::Future, pin::Pin, task::Poll}; -use futures::channel::oneshot; -use futures::{FutureExt, select_biased}; -use gpui::{App, Context, Global, Subscription, Task, Window}; +use gpui::{App, Context, Global, Subscription, Window}; pub use flags::*; @@ -122,11 +118,6 @@ pub struct OnFlagsReady { } pub trait FeatureFlagAppExt { - fn wait_for_flag(&mut self) -> WaitForFlag; - - /// Waits for the specified feature flag to resolve, up to the given timeout. - fn wait_for_flag_or_timeout(&mut self, timeout: Duration) -> Task; - fn update_flags(&mut self, staff: bool, flags: Vec); fn set_staff(&mut self, staff: bool); fn has_flag(&self) -> bool; @@ -192,54 +183,4 @@ impl FeatureFlagAppExt for App { callback(feature_flags.has_flag::(), cx); }) } - - fn wait_for_flag(&mut self) -> WaitForFlag { - let (tx, rx) = oneshot::channel::(); - let mut tx = Some(tx); - let subscription: Option; - - match self.try_global::() { - Some(feature_flags) => { - subscription = None; - tx.take().unwrap().send(feature_flags.has_flag::()).ok(); - } - None => { - subscription = Some(self.observe_global::(move |cx| { - let feature_flags = cx.global::(); - if let Some(tx) = tx.take() { - tx.send(feature_flags.has_flag::()).ok(); - } - })); - } - } - - WaitForFlag(rx, subscription) - } - - fn wait_for_flag_or_timeout(&mut self, timeout: Duration) -> Task { - let wait_for_flag = self.wait_for_flag::(); - - self.spawn(async move |cx| { - let mut wait_for_flag = wait_for_flag.fuse(); - let mut timeout = FutureExt::fuse(cx.background_executor().timer(timeout)); - - select_biased! { - is_enabled = wait_for_flag => is_enabled, - _ = timeout => false, - } - }) - } -} - -pub struct WaitForFlag(oneshot::Receiver, Option); - -impl Future for WaitForFlag { - type Output = bool; - - fn poll(mut self: Pin<&mut Self>, cx: &mut core::task::Context<'_>) -> Poll { - self.0.poll_unpin(cx).map(|result| { - self.1.take(); - result.unwrap_or(false) - }) - } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 079a78225c248e341121f1980a368b37f85eea84..6eee25e6faddae5fdaae7ac2704a10a979b30ce7 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -163,21 +163,24 @@ pub fn init(cx: &mut App) { cx.on_action(quit); cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx)); - let flag = cx.wait_for_flag::(); - cx.spawn(async |cx| { - if cx.update(|cx| ReleaseChannel::global(cx) == ReleaseChannel::Dev) || flag.await { - cx.update(|cx| { - cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) - .on_action(|_: &TestCrash, _| { - unsafe extern "C" { - fn puts(s: *const i8); - } - unsafe { - puts(0xabad1d3a as *const i8); - } - }); - }); - }; + + cx.observe_flag::({ + let mut added = false; + move |enabled, cx| { + if added || !enabled { + return; + } + added = true; + cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) + .on_action(|_: &TestCrash, _| { + unsafe extern "C" { + fn puts(s: *const i8); + } + unsafe { + puts(0xabad1d3a as *const i8); + } + }); + } }) .detach(); cx.on_action(|_: &OpenLog, cx| { From b5666319b4409ca882ace3ad8baaab513e5f3a8c Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 10 Mar 2026 23:45:55 -0700 Subject: [PATCH 17/32] Move threads sidebar into agent panel (#51241) * [x] Put back persistence of sidebar open state * [x] when agent panel is docked right, put sidebar on the right side * [x] remove stale entries from `SidebarsByWindow` Release Notes: - N/A --------- Co-authored-by: Eric Holk Co-authored-by: Mikayla Maki Co-authored-by: Anthony Eid --- Cargo.lock | 29 - Cargo.toml | 3 - crates/agent_ui/Cargo.toml | 1 - crates/agent_ui/src/agent_panel.rs | 249 ++++++++- crates/agent_ui/src/agent_ui.rs | 1 + crates/agent_ui/src/connection_view.rs | 2 +- crates/{sidebar => agent_ui}/src/sidebar.rs | 497 ++++++++---------- .../debugger_ui/src/tests/stack_frame_list.rs | 4 +- .../src/platform_title_bar.rs | 40 +- crates/sidebar/Cargo.toml | 51 -- crates/sidebar/LICENSE-GPL | 1 - crates/title_bar/Cargo.toml | 1 - crates/title_bar/src/title_bar.rs | 125 +---- crates/workspace/src/multi_workspace.rs | 405 +------------- crates/workspace/src/persistence.rs | 10 +- crates/workspace/src/persistence/model.rs | 9 +- crates/workspace/src/status_bar.rs | 14 +- crates/workspace/src/workspace.rs | 29 +- crates/zed/Cargo.toml | 1 - crates/zed/src/visual_test_runner.rs | 42 +- crates/zed/src/zed.rs | 15 - 21 files changed, 517 insertions(+), 1012 deletions(-) rename crates/{sidebar => agent_ui}/src/sidebar.rs (91%) delete mode 100644 crates/sidebar/Cargo.toml delete mode 120000 crates/sidebar/LICENSE-GPL diff --git a/Cargo.lock b/Cargo.lock index dfd8a74acba5056e468a72d8cd105c0f2cfd156a..f11d2023b319501778768fdea39fb8dbb242a9e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15807,33 +15807,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "sidebar" -version = "0.1.0" -dependencies = [ - "acp_thread", - "agent", - "agent-client-protocol", - "agent_ui", - "assistant_text_thread", - "chrono", - "editor", - "feature_flags", - "fs", - "gpui", - "language_model", - "menu", - "project", - "recent_projects", - "serde_json", - "settings", - "theme", - "ui", - "util", - "workspace", - "zed_actions", -] - [[package]] name = "signal-hook" version = "0.3.18" @@ -17660,7 +17633,6 @@ dependencies = [ "client", "cloud_api_types", "db", - "feature_flags", "git_ui", "gpui", "notifications", @@ -21887,7 +21859,6 @@ dependencies = [ "settings_profile_selector", "settings_ui", "shellexpand 2.1.2", - "sidebar", "smol", "snippet_provider", "snippets_ui", diff --git a/Cargo.toml b/Cargo.toml index b6760fa917da7e051fd60a1375be49d516fcf113..c184837bfd6a67490169b7a6908b17b4d61e121f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -173,7 +173,6 @@ members = [ "crates/settings_profile_selector", "crates/settings_ui", "crates/shell_command_parser", - "crates/sidebar", "crates/snippet", "crates/snippet_provider", "crates/snippets_ui", @@ -412,7 +411,6 @@ rules_library = { path = "crates/rules_library" } scheduler = { path = "crates/scheduler" } search = { path = "crates/search" } session = { path = "crates/session" } -sidebar = { path = "crates/sidebar" } settings = { path = "crates/settings" } settings_content = { path = "crates/settings_content" } settings_json = { path = "crates/settings_json" } @@ -907,7 +905,6 @@ refineable = { codegen-units = 1 } release_channel = { codegen-units = 1 } reqwest_client = { codegen-units = 1 } session = { codegen-units = 1 } -sidebar = { codegen-units = 1 } snippet = { codegen-units = 1 } snippets_ui = { codegen-units = 1 } story = { codegen-units = 1 } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 8b06417d2f5812ef2e0fb265e6afa4cfeb26eb3f..7a0910726e03221dc0a105d69c4852e7515e0c35 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -132,7 +132,6 @@ languages = { workspace = true, features = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } - semver.workspace = true reqwest_client.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 80f8925ad05414b9839ac53953156ef35c43e08f..630411c2400ee925f980b5d3a410cb3574e81cd6 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -65,9 +65,10 @@ use extension_host::ExtensionStore; use fs::Fs; use git::repository::validate_worktree_directory; use gpui::{ - Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner, - DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, - Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, + Action, Animation, AnimationExt, AnyElement, AnyView, App, AsyncWindowContext, ClipboardItem, + Corner, DismissEvent, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, + Focusable, KeyContext, MouseButton, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, + deferred, prelude::*, pulsating_between, }; use language::LanguageRegistry; use language_model::{ConfigurationError, LanguageModelRegistry}; @@ -79,15 +80,17 @@ use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::{ - Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, - PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*, + Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator, + KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*, utils::WithRemSize, }; use util::ResultExt as _; use workspace::{ - CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace, - WorkspaceId, + CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar, + MultiWorkspace, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom, + ToolbarItemView, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, + multi_workspace_enabled, }; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, @@ -99,6 +102,55 @@ const AGENT_PANEL_KEY: &str = "agent_panel"; const RECENTLY_UPDATED_MENU_LIMIT: usize = 6; const DEFAULT_THREAD_TITLE: &str = "New Thread"; +#[derive(Default)] +struct SidebarsByWindow( + collections::HashMap>, +); + +impl gpui::Global for SidebarsByWindow {} + +pub(crate) fn sidebar_is_open(window: &Window, cx: &App) -> bool { + if !multi_workspace_enabled(cx) { + return false; + } + let window_id = window.window_handle().window_id(); + cx.try_global::() + .and_then(|sidebars| sidebars.0.get(&window_id)?.upgrade()) + .is_some_and(|sidebar| sidebar.read(cx).is_open()) +} + +fn find_or_create_sidebar_for_window( + window: &mut Window, + cx: &mut App, +) -> Option> { + let window_id = window.window_handle().window_id(); + let multi_workspace = window.root::().flatten()?; + + if !cx.has_global::() { + cx.set_global(SidebarsByWindow::default()); + } + + cx.global_mut::() + .0 + .retain(|_, weak| weak.upgrade().is_some()); + + let existing = cx + .global::() + .0 + .get(&window_id) + .and_then(|weak| weak.upgrade()); + + if let Some(sidebar) = existing { + return Some(sidebar); + } + + let sidebar = cx.new(|cx| crate::sidebar::Sidebar::new(multi_workspace, window, cx)); + cx.global_mut::() + .0 + .insert(window_id, sidebar.downgrade()); + Some(sidebar) +} + fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option { let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY); let key = i64::from(workspace_id).to_string(); @@ -424,6 +476,30 @@ pub fn init(cx: &mut App) { panel.set_start_thread_in(action, cx); }); } + }) + .register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| { + if !multi_workspace_enabled(cx) { + return; + } + if let Some(panel) = workspace.panel::(cx) { + if let Some(sidebar) = panel.read(cx).sidebar.clone() { + sidebar.update(cx, |sidebar, cx| { + sidebar.toggle(window, cx); + }); + } + } + }) + .register_action(|workspace, _: &FocusWorkspaceSidebar, window, cx| { + if !multi_workspace_enabled(cx) { + return; + } + if let Some(panel) = workspace.panel::(cx) { + if let Some(sidebar) = panel.read(cx).sidebar.clone() { + sidebar.update(cx, |sidebar, cx| { + sidebar.focus_or_unfocus(workspace, window, cx); + }); + } + } }); }, ) @@ -820,6 +896,7 @@ pub struct AgentPanel { last_configuration_error_telemetry: Option, on_boarding_upsell_dismissed: AtomicBool, _active_view_observation: Option, + pub(crate) sidebar: Option>, } impl AgentPanel { @@ -991,7 +1068,6 @@ impl AgentPanel { let client = workspace.client().clone(); let workspace_id = workspace.database_id(); let workspace = workspace.weak_handle(); - let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); @@ -1149,10 +1225,17 @@ impl AgentPanel { last_configuration_error_telemetry: None, on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()), _active_view_observation: None, + sidebar: None, }; // Initial sync of agent servers from extensions panel.sync_agent_servers_from_extensions(cx); + + cx.defer_in(window, move |this, window, cx| { + this.sidebar = find_or_create_sidebar_for_window(window, cx); + cx.notify(); + }); + panel } @@ -3526,9 +3609,109 @@ impl AgentPanel { }) } + fn sidebar_info(&self, cx: &App) -> Option<(AnyView, Pixels, bool)> { + if !multi_workspace_enabled(cx) { + return None; + } + let sidebar = self.sidebar.as_ref()?; + let is_open = sidebar.read(cx).is_open(); + let width = sidebar.read(cx).width(cx); + let view: AnyView = sidebar.clone().into(); + Some((view, width, is_open)) + } + + fn render_sidebar_toggle(&self, cx: &Context) -> Option { + if !multi_workspace_enabled(cx) { + return None; + } + let sidebar = self.sidebar.as_ref()?; + let sidebar_read = sidebar.read(cx); + if sidebar_read.is_open() { + return None; + } + let has_notifications = sidebar_read.has_notifications(cx); + + Some( + IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) + .icon_size(IconSize::Small) + .when(has_notifications, |button| { + button + .indicator(Indicator::dot().color(Color::Accent)) + .indicator_border_color(Some(cx.theme().colors().tab_bar_background)) + }) + .tooltip(move |_, cx| { + Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }) + .into_any_element(), + ) + } + + fn render_sidebar(&self, cx: &Context) -> Option { + let (sidebar_view, sidebar_width, is_open) = self.sidebar_info(cx)?; + if !is_open { + return None; + } + + let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; + let sidebar = self.sidebar.as_ref()?.downgrade(); + + let resize_handle = deferred( + div() + .id("sidebar-resize-handle") + .absolute() + .when(docked_right, |this| { + this.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) + .when(!docked_right, |this| { + this.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) + .top(px(0.)) + .h_full() + .w(SIDEBAR_RESIZE_HANDLE_SIZE) + .cursor_col_resize() + .on_drag(DraggedSidebar, |dragged, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| dragged.clone()) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up(MouseButton::Left, move |event, _, cx| { + if event.click_count == 2 { + sidebar + .update(cx, |sidebar, cx| { + sidebar.set_width(None, cx); + }) + .ok(); + cx.stop_propagation(); + } + }) + .occlude(), + ); + + Some( + div() + .id("sidebar-container") + .relative() + .h_full() + .w(sidebar_width) + .flex_shrink_0() + .when(docked_right, |this| this.border_l_1()) + .when(!docked_right, |this| this.border_r_1()) + .border_color(cx.theme().colors().border) + .child(sidebar_view) + .child(resize_handle) + .into_any_element(), + ) + } + fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); + let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; let (selected_agent_custom_icon, selected_agent_label) = if let AgentType::Custom { name, .. } = &self.selected_agent { @@ -3991,6 +4174,9 @@ impl AgentPanel { .size_full() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) + .when(!docked_right, |this| { + this.children(self.render_sidebar_toggle(cx)) + }) .child(agent_selector_menu) .child(self.render_start_thread_in_selector(cx)), ) @@ -4007,7 +4193,10 @@ impl AgentPanel { cx, )) }) - .child(self.render_panel_options_menu(window, cx)), + .child(self.render_panel_options_menu(window, cx)) + .when(docked_right, |this| { + this.children(self.render_sidebar_toggle(cx)) + }), ) .into_any_element() } else { @@ -4045,6 +4234,9 @@ impl AgentPanel { .size_full() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) + .when(!docked_right, |this| { + this.children(self.render_sidebar_toggle(cx)) + }) .child(match &self.active_view { ActiveView::History { .. } | ActiveView::Configuration => { self.render_toolbar_back_button(cx).into_any_element() @@ -4067,7 +4259,10 @@ impl AgentPanel { cx, )) }) - .child(self.render_panel_options_menu(window, cx)), + .child(self.render_panel_options_menu(window, cx)) + .when(docked_right, |this| { + this.children(self.render_sidebar_toggle(cx)) + }), ) .into_any_element() } @@ -4607,14 +4802,44 @@ impl Render for AgentPanel { }) .children(self.render_trial_end_upsell(window, cx)); + let sidebar = self.render_sidebar(cx); + let has_sidebar = sidebar.is_some(); + let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; + + let panel = h_flex() + .size_full() + .when(has_sidebar, |this| { + this.on_drag_move(cx.listener( + move |this, e: &DragMoveEvent, _window, cx| { + if let Some(sidebar) = &this.sidebar { + let width = if docked_right { + e.bounds.right() - e.event.position.x + } else { + e.event.position.x + }; + sidebar.update(cx, |sidebar, cx| { + sidebar.set_width(Some(width), cx); + }); + } + }, + )) + }) + .map(|this| { + if docked_right { + this.child(content).children(sidebar) + } else { + this.children(sidebar).child(content) + } + }); + match self.active_view.which_font_size_used() { WhichFontSize::AgentFont => { WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) .size_full() - .child(content) + .child(panel) .into_any() } - _ => content.into_any(), + _ => panel.into_any(), } } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index d37dbdbbeb184cac31320b4bc9232354eb3dcc8d..292db8fc7c0398fdd8c8800b8acc2b3c6df22740 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -23,6 +23,7 @@ mod mode_selector; mod model_selector; mod model_selector_popover; mod profile_selector; +pub mod sidebar; mod slash_command; mod slash_command_picker; mod terminal_codegen; diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 3b07929813e5583164700905a1fa327f3ac9d964..fd4ac66c05e380ddd3e1c3e2c196c5a397754c9d 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -2340,7 +2340,7 @@ impl ConnectionView { } if let Some(multi_workspace) = window.root::().flatten() { - multi_workspace.read(cx).is_sidebar_open() + crate::agent_panel::sidebar_is_open(window, cx) || self.agent_panel_visible(&multi_workspace, cx) } else { self.workspace diff --git a/crates/sidebar/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs similarity index 91% rename from crates/sidebar/src/sidebar.rs rename to crates/agent_ui/src/sidebar.rs index dd1dcab9ee7b5c6de25630b9f0b8fcebcdad7cb2..2679807388eb6261f9bc32be10c10ed500078b22 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,33 +1,32 @@ +use crate::{AgentPanel, AgentPanelEvent, NewThread}; use acp_thread::ThreadStatus; use agent::ThreadStore; use agent_client_protocol as acp; -use agent_ui::{AgentPanel, AgentPanelEvent, NewThread}; +use agent_settings::AgentSettings; use chrono::Utc; +use db::kvp::KEY_VALUE_STORE; use editor::{Editor, EditorElement, EditorStyle}; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState, + Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, FontStyle, ListState, Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px, relative, rems, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; -use recent_projects::RecentProjects; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; use theme::{ActiveTheme, ThemeSettings}; -use ui::utils::TRAFFIC_LIGHT_PADDING; use ui::{ - AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, - PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, + AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem, + Tooltip, WithScrollbar, prelude::*, }; +use util::ResultExt as _; use util::path_list::PathList; use workspace::{ - FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, - SidebarEvent, ToggleWorkspaceSidebar, Workspace, + MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled, }; -use zed_actions::OpenRecent; use zed_actions::editor::{MoveDown, MoveUp}; actions!( @@ -44,6 +43,27 @@ const DEFAULT_WIDTH: Pixels = px(320.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); const DEFAULT_THREADS_SHOWN: usize = 5; +const SIDEBAR_STATE_KEY: &str = "sidebar_state"; + +fn read_sidebar_open_state(multi_workspace_id: u64) -> bool { + KEY_VALUE_STORE + .scoped(SIDEBAR_STATE_KEY) + .read(&multi_workspace_id.to_string()) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).ok()) + .unwrap_or(false) +} + +async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) { + if let Ok(json) = serde_json::to_string(&is_open) { + KEY_VALUE_STORE + .scoped(SIDEBAR_STATE_KEY) + .write(multi_workspace_id.to_string(), json) + .await + .log_err(); + } +} #[derive(Clone, Debug)] struct ActiveThreadInfo { @@ -173,6 +193,8 @@ fn workspace_path_list_and_label( pub struct Sidebar { multi_workspace: WeakEntity, + persistence_key: Option, + is_open: bool, width: Pixels, focus_handle: FocusHandle, filter_editor: Entity, @@ -186,11 +208,8 @@ pub struct Sidebar { active_entry_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, - recent_projects_popover_handle: PopoverMenuHandle, } -impl EventEmitter for Sidebar {} - impl Sidebar { pub fn new( multi_workspace: Entity, @@ -212,7 +231,6 @@ impl Sidebar { window, |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { MultiWorkspaceEvent::ActiveWorkspaceChanged => { - this.focused_thread = None; this.update_entries(cx); } MultiWorkspaceEvent::WorkspaceAdded(workspace) => { @@ -270,8 +288,15 @@ impl Sidebar { this.update_entries(cx); }); + let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0); + let is_open = persistence_key + .map(read_sidebar_open_state) + .unwrap_or(false); + Self { multi_workspace: multi_workspace.downgrade(), + persistence_key, + is_open, width: DEFAULT_WIDTH, focus_handle, filter_editor, @@ -282,7 +307,6 @@ impl Sidebar { active_entry_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), - recent_projects_popover_handle: PopoverMenuHandle::default(), } } @@ -334,31 +358,10 @@ impl Sidebar { cx.subscribe_in( agent_panel, window, - |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { - AgentPanelEvent::ActiveViewChanged => { - match agent_panel.read(cx).active_connection_view() { - Some(thread) => { - if let Some(session_id) = thread.read(cx).parent_id(cx) { - this.focused_thread = Some(session_id); - } - } - None => { - this.focused_thread = None; - } - } - this.update_entries(cx); - } - AgentPanelEvent::ThreadFocused => { - let new_focused = agent_panel - .read(cx) - .active_connection_view() - .and_then(|thread| thread.read(cx).parent_id(cx)); - if new_focused.is_some() && new_focused != this.focused_thread { - this.focused_thread = new_focused; - this.update_entries(cx); - } - } - AgentPanelEvent::BackgroundThreadChanged => { + |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { + AgentPanelEvent::ActiveViewChanged + | AgentPanelEvent::ThreadFocused + | AgentPanelEvent::BackgroundThreadChanged => { this.update_entries(cx); } }, @@ -419,6 +422,12 @@ impl Sidebar { let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); + self.focused_thread = active_workspace + .as_ref() + .and_then(|ws| ws.read(cx).panel::(cx)) + .and_then(|panel| panel.read(cx).active_connection_view().cloned()) + .and_then(|cv| cv.read(cx).parent_id(cx)); + let thread_store = ThreadStore::try_global(cx); let query = self.filter_editor.read(cx).text(cx); @@ -657,7 +666,7 @@ impl Sidebar { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; - if !multi_workspace.read(cx).multi_workspace_enabled(cx) { + if !multi_workspace_enabled(cx) { return; } @@ -885,8 +894,6 @@ impl Sidebar { return; }; - self.focused_thread = None; - multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), cx); }); @@ -1173,48 +1180,6 @@ impl Sidebar { .into_any_element() } - fn render_recent_projects_button(&self, cx: &mut Context) -> impl IntoElement { - let workspace = self - .multi_workspace - .upgrade() - .map(|mw| mw.read(cx).workspace().downgrade()); - - let focus_handle = workspace - .as_ref() - .and_then(|ws| ws.upgrade()) - .map(|w| w.read(cx).focus_handle(cx)) - .unwrap_or_else(|| cx.focus_handle()); - - let popover_handle = self.recent_projects_popover_handle.clone(); - - PopoverMenu::new("sidebar-recent-projects-menu") - .with_handle(popover_handle) - .menu(move |window, cx| { - workspace.as_ref().map(|ws| { - RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx) - }) - }) - .trigger_with_tooltip( - IconButton::new("open-project", IconName::OpenFolder) - .icon_size(IconSize::Small) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)), - |_window, cx| { - Tooltip::for_action( - "Recent Projects", - &OpenRecent { - create_new_window: false, - }, - cx, - ) - }, - ) - .anchor(gpui::Corner::TopLeft) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }) - } - fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { let settings = ThemeSettings::get_global(cx); let text_style = TextStyle { @@ -1343,26 +1308,66 @@ impl Sidebar { } } -impl WorkspaceSidebar for Sidebar { - fn width(&self, _cx: &App) -> Pixels { - self.width +impl Sidebar { + pub fn is_open(&self) -> bool { + self.is_open } - fn set_width(&mut self, width: Option, cx: &mut Context) { - self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); + pub fn set_open(&mut self, open: bool, cx: &mut Context) { + if self.is_open == open { + return; + } + self.is_open = open; cx.notify(); + if let Some(key) = self.persistence_key { + let is_open = self.is_open; + cx.background_spawn(async move { + save_sidebar_open_state(key, is_open).await; + }) + .detach(); + } } - fn has_notifications(&self, _cx: &App) -> bool { - !self.contents.notified_threads.is_empty() + pub fn toggle(&mut self, window: &mut Window, cx: &mut Context) { + let new_state = !self.is_open; + self.set_open(new_state, cx); + if new_state { + cx.focus_self(window); + } + } + + pub fn focus_or_unfocus( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + if self.is_open { + let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx); + if sidebar_is_focused { + let active_pane = workspace.active_pane().clone(); + let pane_focus = active_pane.read(cx).focus_handle(cx); + window.focus(&pane_focus, cx); + } else { + cx.focus_self(window); + } + } else { + self.set_open(true, cx); + cx.focus_self(window); + } } - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { - self.recent_projects_popover_handle.toggle(window, cx); + pub fn width(&self, _cx: &App) -> Pixels { + self.width + } + + pub fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); + cx.notify(); } - fn is_recent_projects_popover_deployed(&self) -> bool { - self.recent_projects_popover_handle.is_deployed() + pub fn has_notifications(&self, _cx: &App) -> bool { + !self.contents.notified_threads.is_empty() } } @@ -1374,18 +1379,9 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let titlebar_height = ui::utils::platform_title_bar_height(window); let ui_font = theme::setup_ui_font(window, cx); - let is_focused = self.focus_handle.is_focused(window) - || self.filter_editor.focus_handle(cx).is_focused(window); let has_query = self.has_filter_query(cx); - let focus_tooltip_label = if is_focused { - "Focus Workspace" - } else { - "Focus Sidebar" - }; - v_flex() .id("workspace-sidebar") .key_context("WorkspaceSidebar") @@ -1401,69 +1397,26 @@ impl Render for Sidebar { .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::cancel)) .font(ui_font) - .h_full() - .w(self.width) + .size_full() .bg(cx.theme().colors().surface_background) - .border_r_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .flex_none() - .h(titlebar_height) - .w_full() - .mt_px() - .pb_px() - .pr_1() - .when_else( - cfg!(target_os = "macos") && !window.is_fullscreen(), - |this| this.pl(px(TRAFFIC_LIGHT_PADDING)), - |this| this.pl_2(), - ) - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child({ - let focus_handle_toggle = self.focus_handle.clone(); - let focus_handle_focus = self.focus_handle.clone(); - IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) - .icon_size(IconSize::Small) - .tooltip(Tooltip::element(move |_, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Close Sidebar")) - .child(KeyBinding::for_action_in( - &ToggleWorkspaceSidebar, - &focus_handle_toggle, - cx, - )), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .justify_between() - .child(Label::new(focus_tooltip_label)) - .child(KeyBinding::for_action_in( - &FocusWorkspaceSidebar, - &focus_handle_focus, - cx, - )), - ) - .into_any_element() - })) - .on_click(cx.listener(|_this, _, _window, cx| { - cx.emit(SidebarEvent::Close); - })) - }) - .child(self.render_recent_projects_button(cx)), - ) - .child( + .child({ + let docked_right = + AgentSettings::get_global(cx).dock == settings::DockPosition::Right; + let render_close_button = || { + IconButton::new("sidebar-close-toggle", IconName::WorkspaceNavOpen) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action( + "Close Threads Sidebar", + &ToggleWorkspaceSidebar, + cx, + ) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }) + }; + h_flex() .flex_none() .px_2p5() @@ -1471,6 +1424,7 @@ impl Render for Sidebar { .gap_2() .border_b_1() .border_color(cx.theme().colors().border) + .when(!docked_right, |this| this.child(render_close_button())) .child( Icon::new(IconName::MagnifyingGlass) .size(IconSize::Small) @@ -1487,8 +1441,9 @@ impl Render for Sidebar { this.update_entries(cx); })), ) - }), - ) + }) + .when(docked_right, |this| this.child(render_close_button())) + }) .child( v_flex() .flex_1() @@ -1509,26 +1464,24 @@ impl Render for Sidebar { #[cfg(test)] mod tests { use super::*; + use crate::test_support::{active_session_id, open_thread_with_connection, send_message}; use acp_thread::StubAgentConnection; use agent::ThreadStore; - use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; use assistant_text_thread::TextThreadStore; use chrono::DateTime; use feature_flags::FeatureFlagAppExt as _; use fs::FakeFs; use gpui::TestAppContext; - use settings::SettingsStore; use std::sync::Arc; use util::path_list::PathList; fn init_test(cx: &mut TestAppContext) { + crate::test_support::init_test(cx); cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); }); } @@ -1569,14 +1522,33 @@ mod tests { multi_workspace: &Entity, cx: &mut gpui::VisualTestContext, ) -> Entity { - let multi_workspace = multi_workspace.clone(); - let sidebar = - cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.register_sidebar(sidebar.clone(), window, cx); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx); + sidebar + } + + fn setup_sidebar_with_agent_panel( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> (Entity, Entity) { + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + let project = workspace.read_with(cx, |ws, _cx| ws.project().clone()); + let panel = add_agent_panel(&workspace, &project, cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace.right_dock().update(cx, |dock, cx| { + if let Some(panel_ix) = dock.panel_index_for_type::() { + dock.activate_panel(panel_ix, window, cx); + } + dock.set_open(true, window, cx); + }); }); cx.run_until_parked(); - sidebar + let sidebar = panel.read_with(cx, |panel, _cx| { + panel + .sidebar + .clone() + .expect("AgentPanel should have created a sidebar") + }); + (sidebar, panel) } async fn save_n_test_threads( @@ -1623,16 +1595,10 @@ mod tests { cx.run_until_parked(); } - fn open_and_focus_sidebar( - sidebar: &Entity, - multi_workspace: &Entity, - cx: &mut gpui::VisualTestContext, - ) { - multi_workspace.update_in(cx, |mw, window, cx| { - mw.toggle_sidebar(window, cx); - }); + fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { cx.run_until_parked(); - sidebar.update_in(cx, |_, window, cx| { + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.set_open(true, cx); cx.focus_self(window); }); cx.run_until_parked(); @@ -1886,7 +1852,7 @@ mod tests { assert!(entries.iter().any(|e| e.contains("View More (12)"))); // Focus and navigate to View More, then confirm to expand by one batch - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); for _ in 0..7 { cx.dispatch_action(SelectNext); } @@ -2169,7 +2135,7 @@ mod tests { // Entries: [header, thread3, thread2, thread1] // Focusing the sidebar does not set a selection; select_next/select_previous // handle None gracefully by starting from the first or last entry. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // First SelectNext from None starts at index 0 @@ -2218,7 +2184,7 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); // SelectLast jumps to the end cx.dispatch_action(SelectLast); @@ -2241,7 +2207,7 @@ mod tests { // Open the sidebar so it's rendered, then focus it to trigger focus_in. // focus_in no longer sets a default selection. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // Manually set a selection, blur, then refocus — selection should be preserved @@ -2273,6 +2239,9 @@ mod tests { }); cx.run_until_parked(); + // Add an agent panel to workspace 1 so the sidebar renders when it's active. + setup_sidebar_with_agent_panel(&multi_workspace, cx); + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(1, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); @@ -2299,7 +2268,7 @@ mod tests { ); // Focus the sidebar and manually select the header (index 0) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); @@ -2342,7 +2311,7 @@ mod tests { assert!(entries.iter().any(|e| e.contains("View More (3)"))); // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); for _ in 0..7 { cx.dispatch_action(SelectNext); } @@ -2377,7 +2346,7 @@ mod tests { ); // Focus sidebar and manually select the header (index 0). Press left to collapse. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); @@ -2417,7 +2386,7 @@ mod tests { cx.run_until_parked(); // Focus sidebar (selection starts at None), then navigate down to the thread (child) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); @@ -2452,7 +2421,7 @@ mod tests { ); // Focus sidebar — focus_in does not set a selection - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // First SelectNext from None starts at index 0 (header) @@ -2485,7 +2454,7 @@ mod tests { cx.run_until_parked(); // Focus sidebar (selection starts at None), navigate down to the thread (index 1) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); @@ -2505,24 +2474,6 @@ mod tests { ); } - async fn init_test_project_with_agent_panel( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - fn add_agent_panel( workspace: &Entity, project: &Entity, @@ -2536,23 +2487,12 @@ mod tests { }) } - fn setup_sidebar_with_agent_panel( - multi_workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> (Entity, Entity) { - let sidebar = setup_sidebar(multi_workspace, cx); - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - let panel = add_agent_panel(&workspace, project, cx); - (sidebar, panel) - } - #[gpui::test] async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; + let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); @@ -2595,10 +2535,10 @@ mod tests { #[gpui::test] async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let project_a = init_test_project("/project-a", cx).await; let (multi_workspace, cx) = cx .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); @@ -2802,7 +2742,7 @@ mod tests { ); // User types a search query to filter down. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); type_in_search(&sidebar, "alpha", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), @@ -3125,7 +3065,7 @@ mod tests { // User focuses the sidebar and collapses the group using keyboard: // manually select the header, then press CollapseSelectedEntry to collapse. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); @@ -3175,7 +3115,7 @@ mod tests { } cx.run_until_parked(); - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); // User types "fix" — two threads match. type_in_search(&sidebar, "fix", cx); @@ -3352,10 +3292,10 @@ mod tests { #[gpui::test] async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; + let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); @@ -3400,10 +3340,10 @@ mod tests { #[gpui::test] async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let project_a = init_test_project("/project-a", cx).await; let (multi_workspace, cx) = cx .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); @@ -3432,7 +3372,8 @@ mod tests { let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); // ── 1. Initial state: no focused thread ────────────────────────────── - // Workspace B is active (just added), so its header is the active entry. + // Workspace B is active (just added) and has no thread, so its header + // is the active entry. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread, None, @@ -3447,6 +3388,7 @@ mod tests { ); }); + // ── 2. Click thread in workspace A via sidebar ─────────────────────── sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_thread( acp_thread::AgentSessionInfo { @@ -3490,6 +3432,7 @@ mod tests { ); }); + // ── 3. Open thread in workspace B, then click it via sidebar ───────── let connection_b = StubAgentConnection::new(); connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Thread B".into()), @@ -3501,6 +3444,16 @@ mod tests { save_thread_to_store(&session_id_b, &path_list_b, cx).await; cx.run_until_parked(); + // Opening a thread in a non-active workspace should NOT change + // focused_thread — it's derived from the active workspace. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Opening a thread in a non-active workspace should not affect focused_thread" + ); + }); + // Workspace A is currently active. Click a thread in workspace B, // which also triggers a workspace switch. sidebar.update_in(cx, |sidebar, window, cx| { @@ -3535,25 +3488,30 @@ mod tests { ); }); + // ── 4. Switch workspace → focused_thread reflects new workspace ────── multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_next_workspace(window, cx); }); cx.run_until_parked(); + // Workspace A is now active. Its agent panel still has session_id_a + // loaded, so focused_thread should reflect that. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( - sidebar.focused_thread, None, - "External workspace switch should clear focused_thread" + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Switching workspaces should derive focused_thread from the new active workspace" ); let active_entry = sidebar .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the workspace header after external switch" + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a), + "Active entry should be workspace_a's active thread" ); }); + // ── 5. Opening a thread in a non-active workspace is ignored ────────── let connection_b2 = StubAgentConnection::new(); connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("New thread".into()), @@ -3564,69 +3522,48 @@ mod tests { save_thread_to_store(&session_id_b2, &path_list_b, cx).await; cx.run_until_parked(); + // Workspace A is still active, so focused_thread stays on session_id_a. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Opening a thread externally should set focused_thread" - ); - }); - - workspace_b.update_in(cx, |workspace, window, cx| { - workspace.focus_handle(cx).focus(window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Defocusing the sidebar should not clear focused_thread" + Some(&session_id_a), + "Opening a thread in a non-active workspace should not affect focused_thread" ); }); + // ── 6. Activating workspace B shows its active thread ──────────────── sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_workspace(&workspace_b, window, cx); }); cx.run_until_parked(); + // Workspace B is now active with session_id_b2 loaded. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( - sidebar.focused_thread, None, - "Clicking a workspace header should clear focused_thread" + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Activating workspace_b should show workspace_b's active thread" ); let active_entry = sidebar .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the workspace header" + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2), + "Active entry should be workspace_b's active thread" ); }); - // ── 8. Focusing the agent panel thread restores focused_thread ──── - // Workspace B still has session_id_b2 loaded in the agent panel. - // Clicking into the thread (simulated by focusing its view) should - // set focused_thread via the ThreadFocused event. - panel_b.update_in(cx, |panel, window, cx| { - if let Some(thread_view) = panel.active_connection_view() { - thread_view.read(cx).focus_handle(cx).focus(window, cx); - } + // ── 7. Switching back to workspace A reflects its thread ───────────── + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_next_workspace(window, cx); }); cx.run_until_parked(); sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Focusing the agent panel thread should set focused_thread" - ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2), - "Active entry should be the focused thread" + Some(&session_id_a), + "Switching back to workspace_a should show its active thread" ); }); } diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 1f5ac5dea4a19af338feceaa2ee51fd9322fa9a5..9a9a9316fb09def438f78734831c5e560c838fba 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1211,7 +1211,9 @@ async fn test_stack_frame_filter_persistence( cx.run_until_parked(); let workspace_id = workspace - .update(cx, |workspace, _window, cx| workspace.database_id(cx)) + .update(cx, |workspace, _window, cx| { + workspace.active_workspace_database_id(cx) + }) .ok() .flatten() .expect("workspace id has to be some for this test to work properly"); diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index 7053fe89e7fdc6ece9ad50fdd8facaf31dba3086..1db29b0f53d9e7b185e6c3cd3029ed2e6077753e 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -31,8 +31,6 @@ pub struct PlatformTitleBar { children: SmallVec<[AnyElement; 2]>, should_move: bool, system_window_tabs: Entity, - workspace_sidebar_open: bool, - sidebar_has_notifications: bool, } impl PlatformTitleBar { @@ -46,8 +44,6 @@ impl PlatformTitleBar { children: SmallVec::new(), should_move: false, system_window_tabs, - workspace_sidebar_open: false, - sidebar_has_notifications: false, } } @@ -74,28 +70,6 @@ impl PlatformTitleBar { SystemWindowTabs::init(cx); } - pub fn is_workspace_sidebar_open(&self) -> bool { - self.workspace_sidebar_open - } - - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); - } - - pub fn sidebar_has_notifications(&self) -> bool { - self.sidebar_has_notifications - } - - pub fn set_sidebar_has_notifications( - &mut self, - has_notifications: bool, - cx: &mut Context, - ) { - self.sidebar_has_notifications = has_notifications; - cx.notify(); - } - pub fn is_multi_workspace_enabled(cx: &App) -> bool { cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai } @@ -110,9 +84,6 @@ impl Render for PlatformTitleBar { let close_action = Box::new(workspace::CloseWindow); let children = mem::take(&mut self.children); - let is_multiworkspace_sidebar_open = - PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open(); - let title_bar = h_flex() .window_control_area(WindowControlArea::Drag) .w_full() @@ -161,9 +132,7 @@ impl Render for PlatformTitleBar { .map(|this| { if window.is_fullscreen() { this.pl_2() - } else if self.platform_style == PlatformStyle::Mac - && !is_multiworkspace_sidebar_open - { + } else if self.platform_style == PlatformStyle::Mac { this.pl(px(TRAFFIC_LIGHT_PADDING)) } else { this.pl_2() @@ -175,10 +144,9 @@ impl Render for PlatformTitleBar { .when(!(tiling.top || tiling.right), |el| { el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING) }) - .when( - !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open, - |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING), - ) + .when(!(tiling.top || tiling.left), |el| { + el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING) + }) // this border is to avoid a transparent gap in the rounded corners .mt(px(-1.)) .mb(px(-1.)) diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml deleted file mode 100644 index e6b873704ffda9d241fec002eb0fdff0af979c48..0000000000000000000000000000000000000000 --- a/crates/sidebar/Cargo.toml +++ /dev/null @@ -1,51 +0,0 @@ -[package] -name = "sidebar" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/sidebar.rs" - -[features] -default = [] - -[dependencies] -acp_thread.workspace = true -agent.workspace = true -agent-client-protocol.workspace = true -agent_ui.workspace = true -chrono.workspace = true -editor.workspace = true -feature_flags.workspace = true -fs.workspace = true -gpui.workspace = true -menu.workspace = true -project.workspace = true -recent_projects.workspace = true -settings.workspace = true -theme.workspace = true -ui.workspace = true -util.workspace = true -workspace.workspace = true -zed_actions.workspace = true - -[dev-dependencies] -acp_thread = { workspace = true, features = ["test-support"] } -agent = { workspace = true, features = ["test-support"] } -agent_ui = { workspace = true, features = ["test-support"] } -assistant_text_thread = { workspace = true, features = ["test-support"] } -editor.workspace = true -language_model = { workspace = true, features = ["test-support"] } -serde_json.workspace = true -feature_flags.workspace = true -fs = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -project = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } -recent_projects = { workspace = true, features = ["test-support"] } diff --git a/crates/sidebar/LICENSE-GPL b/crates/sidebar/LICENSE-GPL deleted file mode 120000 index 89e542f750cd3860a0598eff0dc34b56d7336dc4..0000000000000000000000000000000000000000 --- a/crates/sidebar/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index b5c10835c6bf85ea24db1ff9bad5abbbf3b517ee..f6483d1d70d4017edf8ab8b188d67ecf85e19aef 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -38,7 +38,6 @@ chrono.workspace = true client.workspace = true cloud_api_types.workspace = true db.workspace = true -feature_flags.workspace = true git_ui.workspace = true gpui = { workspace = true, features = ["screen-capture"] } notifications.workspace = true diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 96cc929c06039c14a9ce4eaa05fd067fbd95b7d0..916d58426b76f020bce8a9bf69971f34bc3803a4 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -24,16 +24,13 @@ use auto_update::AutoUpdateStatus; use call::ActiveCall; use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; -use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ Action, AnyElement, App, Context, Corner, Element, Empty, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, }; use onboarding_banner::OnboardingBanner; -use project::{ - DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees, -}; +use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees}; use remote::RemoteConnectionOptions; use settings::Settings; use settings::WorktreeId; @@ -47,8 +44,7 @@ use ui::{ use update_version::UpdateVersion; use util::ResultExt; use workspace::{ - MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace, - notifications::NotifyResultExt, + MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt, }; use zed_actions::OpenRemote; @@ -174,7 +170,6 @@ impl Render for TitleBar { let mut render_project_items = title_bar_settings.show_branch_name || title_bar_settings.show_project_items; title_bar - .children(self.render_workspace_sidebar_toggle(window, cx)) .when_some( self.application_menu.clone().filter(|_| !show_menus), |title_bar, menu| { @@ -357,7 +352,6 @@ impl TitleBar { // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar. { - let platform_titlebar = platform_titlebar.clone(); let window_handle = window.window_handle(); cx.spawn(async move |this: WeakEntity, cx| { let Some(multi_workspace_handle) = window_handle.downcast::() @@ -370,26 +364,8 @@ impl TitleBar { return; }; - let is_open = multi_workspace.read(cx).is_sidebar_open(); - let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - titlebar.set_sidebar_has_notifications(has_notifications, cx); - }); - - let platform_titlebar = platform_titlebar.clone(); - let subscription = cx.observe(&multi_workspace, move |mw, cx| { - let is_open = mw.read(cx).is_sidebar_open(); - let has_notifications = mw.read(cx).sidebar_has_notifications(cx); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - titlebar.set_sidebar_has_notifications(has_notifications, cx); - }); - }); - if let Some(this) = this.upgrade() { this.update(cx, |this, _| { - this._subscriptions.push(subscription); this.multi_workspace = Some(multi_workspace.downgrade()); }); } @@ -686,46 +662,7 @@ impl TitleBar { ) } - fn render_workspace_sidebar_toggle( - &self, - _window: &mut Window, - cx: &mut Context, - ) -> Option { - if !cx.has_flag::() || DisableAiSettings::get_global(cx).disable_ai { - return None; - } - - let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); - - if is_sidebar_open { - return None; - } - - let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications(); - - Some( - IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) - .icon_size(IconSize::Small) - .when(has_notifications, |button| { - button - .indicator(Indicator::dot().color(Color::Accent)) - .indicator_border_color(Some(cx.theme().colors().title_bar_background)) - }) - .tooltip(move |_, cx| { - Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); - }) - .into_any_element(), - ) - } - - pub fn render_project_name( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { + pub fn render_project_name(&self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let workspace = self.workspace.clone(); let name = self.effective_active_worktree(cx).map(|worktree| { @@ -741,19 +678,6 @@ impl TitleBar { "Open Recent Project".to_string() }; - let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); - - if is_sidebar_open { - return self - .render_project_name_with_sidebar_popover( - window, - display_name, - is_project_selected, - cx, - ) - .into_any_element(); - } - let focus_handle = workspace .upgrade() .map(|w| w.read(cx).focus_handle(cx)) @@ -793,49 +717,6 @@ impl TitleBar { .into_any_element() } - fn render_project_name_with_sidebar_popover( - &self, - _window: &Window, - display_name: String, - is_project_selected: bool, - cx: &mut Context, - ) -> impl IntoElement { - let multi_workspace = self.multi_workspace.clone(); - - let is_popover_deployed = multi_workspace - .as_ref() - .and_then(|mw| mw.upgrade()) - .map(|mw| mw.read(cx).is_recent_projects_popover_deployed(cx)) - .unwrap_or(false); - - Button::new("project_name_trigger", display_name) - .label_size(LabelSize::Small) - .when(self.worktree_count(cx) > 1, |this| { - this.icon(IconName::ChevronDown) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - }) - .toggle_state(is_popover_deployed) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .when(!is_project_selected, |s| s.color(Color::Muted)) - .tooltip(move |_window, cx| { - Tooltip::for_action( - "Recent Projects", - &zed_actions::OpenRecent { - create_new_window: false, - }, - cx, - ) - }) - .on_click(move |_, window, cx| { - if let Some(mw) = multi_workspace.as_ref().and_then(|mw| mw.upgrade()) { - mw.update(cx, |mw, cx| { - mw.toggle_recent_projects_popover(window, cx); - }); - } - }) - } - pub fn render_project_branch(&self, cx: &mut Context) -> Option { let effective_worktree = self.effective_active_worktree(cx)?; let repository = self.get_repository_for_worktree(&effective_worktree, cx)?; diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 26af1ce27ecc28b7b541625a16731d0d721a7fc9..adfc62a2bd210b4da24202d734ba9f9eedd17aef 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1,9 +1,8 @@ use anyhow::Result; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ - AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId, - actions, deferred, px, + App, Context, Entity, EntityId, EventEmitter, Focusable, ManagedView, Pixels, Render, + Subscription, Task, Tiling, Window, WindowId, actions, px, }; use project::{DisableAiSettings, Project}; use settings::Settings; @@ -12,11 +11,12 @@ use std::path::PathBuf; use ui::prelude::*; use util::ResultExt; -const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +pub const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); use crate::{ CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast, Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId, + persistence::model::MultiWorkspaceId, }; actions!( @@ -41,31 +41,6 @@ pub enum MultiWorkspaceEvent { WorkspaceRemoved(EntityId), } -pub enum SidebarEvent { - Open, - Close, -} - -pub trait Sidebar: EventEmitter + Focusable + Render + Sized { - fn width(&self, cx: &App) -> Pixels; - fn set_width(&mut self, width: Option, cx: &mut Context); - fn has_notifications(&self, cx: &App) -> bool; - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App); - fn is_recent_projects_popover_deployed(&self) -> bool; -} - -pub trait SidebarHandle: 'static + Send + Sync { - fn width(&self, cx: &App) -> Pixels; - fn set_width(&self, width: Option, cx: &mut App); - fn focus_handle(&self, cx: &App) -> FocusHandle; - fn focus(&self, window: &mut Window, cx: &mut App); - fn has_notifications(&self, cx: &App) -> bool; - fn to_any(&self) -> AnyView; - fn entity_id(&self) -> EntityId; - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App); - fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool; -} - #[derive(Clone)] pub struct DraggedSidebar; @@ -75,54 +50,11 @@ impl Render for DraggedSidebar { } } -impl SidebarHandle for Entity { - fn width(&self, cx: &App) -> Pixels { - self.read(cx).width(cx) - } - - fn set_width(&self, width: Option, cx: &mut App) { - self.update(cx, |this, cx| this.set_width(width, cx)) - } - - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.read(cx).focus_handle(cx) - } - - fn focus(&self, window: &mut Window, cx: &mut App) { - let handle = self.read(cx).focus_handle(cx); - window.focus(&handle, cx); - } - - fn has_notifications(&self, cx: &App) -> bool { - self.read(cx).has_notifications(cx) - } - - fn to_any(&self) -> AnyView { - self.clone().into() - } - - fn entity_id(&self) -> EntityId { - Entity::entity_id(self) - } - - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { - self.update(cx, |this, cx| { - this.toggle_recent_projects_popover(window, cx); - }); - } - - fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool { - self.read(cx).is_recent_projects_popover_deployed() - } -} - pub struct MultiWorkspace { window_id: WindowId, workspaces: Vec>, + database_id: Option, active_workspace_index: usize, - sidebar: Option>, - sidebar_open: bool, - _sidebar_subscription: Option, pending_removal_tasks: Vec>, _serialize_task: Option>, _create_task: Option>, @@ -131,6 +63,10 @@ pub struct MultiWorkspace { impl EventEmitter for MultiWorkspace {} +pub fn multi_workspace_enabled(cx: &App) -> bool { + cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai +} + impl MultiWorkspace { pub fn new(workspace: Entity, window: &mut Window, cx: &mut Context) -> Self { let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| { @@ -145,142 +81,17 @@ impl MultiWorkspace { } }); let quit_subscription = cx.on_app_quit(Self::app_will_quit); - let settings_subscription = - cx.observe_global_in::(window, |this, window, cx| { - if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open { - this.close_sidebar(window, cx); - } - }); Self::subscribe_to_workspace(&workspace, cx); Self { window_id: window.window_handle().window_id(), + database_id: None, workspaces: vec![workspace], active_workspace_index: 0, - sidebar: None, - sidebar_open: false, - _sidebar_subscription: None, pending_removal_tasks: Vec::new(), _serialize_task: None, _create_task: None, - _subscriptions: vec![ - release_subscription, - quit_subscription, - settings_subscription, - ], - } - } - - pub fn register_sidebar( - &mut self, - sidebar: Entity, - window: &mut Window, - cx: &mut Context, - ) { - let subscription = - cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event { - SidebarEvent::Open => this.toggle_sidebar(window, cx), - SidebarEvent::Close => { - this.close_sidebar(window, cx); - } - }); - self.sidebar = Some(Box::new(sidebar)); - self._sidebar_subscription = Some(subscription); - } - - pub fn sidebar(&self) -> Option<&dyn SidebarHandle> { - self.sidebar.as_deref() - } - - pub fn sidebar_open(&self) -> bool { - self.sidebar_open && self.sidebar.is_some() - } - - pub fn sidebar_has_notifications(&self, cx: &App) -> bool { - self.sidebar - .as_ref() - .map_or(false, |s| s.has_notifications(cx)) - } - - pub fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { - if let Some(sidebar) = &self.sidebar { - sidebar.toggle_recent_projects_popover(window, cx); - } - } - - pub fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool { - self.sidebar - .as_ref() - .map_or(false, |s| s.is_recent_projects_popover_deployed(cx)) - } - - pub fn multi_workspace_enabled(&self, cx: &App) -> bool { - cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai - } - - pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { - return; - } - - if self.sidebar_open { - self.close_sidebar(window, cx); - } else { - self.open_sidebar(cx); - if let Some(sidebar) = &self.sidebar { - sidebar.focus(window, cx); - } - } - } - - pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { - return; - } - - if self.sidebar_open { - let sidebar_is_focused = self - .sidebar - .as_ref() - .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx)); - - if sidebar_is_focused { - let pane = self.workspace().read(cx).active_pane().clone(); - let pane_focus = pane.read(cx).focus_handle(cx); - window.focus(&pane_focus, cx); - } else if let Some(sidebar) = &self.sidebar { - sidebar.focus(window, cx); - } - } else { - self.open_sidebar(cx); - if let Some(sidebar) = &self.sidebar { - sidebar.focus(window, cx); - } - } - } - - pub fn open_sidebar(&mut self, cx: &mut Context) { - self.sidebar_open = true; - for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, cx); - }); - } - self.serialize(cx); - cx.notify(); - } - - fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - self.sidebar_open = false; - for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(false, cx); - }); + _subscriptions: vec![release_subscription, quit_subscription], } - let pane = self.workspace().read(cx).active_pane().clone(); - let pane_focus = pane.read(cx).focus_handle(cx); - window.focus(&pane_focus, cx); - self.serialize(cx); - cx.notify(); } pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context) { @@ -318,10 +129,6 @@ impl MultiWorkspace { .detach(); } - pub fn is_sidebar_open(&self) -> bool { - self.sidebar_open - } - pub fn workspace(&self) -> &Entity { &self.workspaces[self.active_workspace_index] } @@ -335,7 +142,7 @@ impl MultiWorkspace { } pub fn activate(&mut self, workspace: Entity, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { + if !multi_workspace_enabled(cx) { self.workspaces[0] = workspace; self.active_workspace_index = 0; cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); @@ -371,11 +178,6 @@ impl MultiWorkspace { if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { index } else { - if self.sidebar_open { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, cx); - }); - } Self::subscribe_to_workspace(&workspace, cx); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); @@ -384,6 +186,14 @@ impl MultiWorkspace { } } + pub fn database_id(&self) -> Option { + self.database_id + } + + pub fn set_database_id(&mut self, id: Option) { + self.database_id = id; + } + pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context) { debug_assert!( index < self.workspaces.len(), @@ -421,7 +231,6 @@ impl MultiWorkspace { let window_id = self.window_id; let state = crate::persistence::model::MultiWorkspaceState { active_workspace_id: self.workspace().read(cx).database_id(), - sidebar_open: self.sidebar_open, }; self._serialize_task = Some(cx.background_spawn(async move { crate::persistence::write_multi_workspace_state(window_id, state).await; @@ -540,7 +349,7 @@ impl MultiWorkspace { self.workspace().read(cx).items_of_type::(cx) } - pub fn database_id(&self, cx: &App) -> Option { + pub fn active_workspace_database_id(&self, cx: &App) -> Option { self.workspace().read(cx).database_id() } @@ -583,7 +392,7 @@ impl MultiWorkspace { } pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { + if !multi_workspace_enabled(cx) { return; } let app_state = self.workspace().read(cx).app_state().clone(); @@ -692,7 +501,7 @@ impl MultiWorkspace { ) -> Task> { let workspace = self.workspace().clone(); - if self.multi_workspace_enabled(cx) { + if multi_workspace_enabled(cx) { workspace.update(cx, |workspace, cx| { workspace.open_workspace_for_paths(true, paths, window, cx) }) @@ -719,57 +528,6 @@ impl MultiWorkspace { impl Render for MultiWorkspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let multi_workspace_enabled = self.multi_workspace_enabled(cx); - - let sidebar: Option = if multi_workspace_enabled && self.sidebar_open { - self.sidebar.as_ref().map(|sidebar_handle| { - let weak = cx.weak_entity(); - - let sidebar_width = sidebar_handle.width(cx); - let resize_handle = deferred( - div() - .id("sidebar-resize-handle") - .absolute() - .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) - .top(px(0.)) - .h_full() - .w(SIDEBAR_RESIZE_HANDLE_SIZE) - .cursor_col_resize() - .on_drag(DraggedSidebar, |dragged, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| dragged.clone()) - }) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up(MouseButton::Left, move |event, _, cx| { - if event.click_count == 2 { - weak.update(cx, |this, cx| { - if let Some(sidebar) = this.sidebar.as_mut() { - sidebar.set_width(None, cx); - } - }) - .ok(); - cx.stop_propagation(); - } - }) - .occlude(), - ); - - div() - .id("sidebar-container") - .relative() - .h_full() - .w(sidebar_width) - .flex_shrink_0() - .child(sidebar_handle.to_any()) - .child(resize_handle) - .into_any_element() - }) - } else { - None - }; - let ui_font = theme::setup_ui_font(window, cx); let text_color = cx.theme().colors().text; @@ -799,32 +557,6 @@ impl Render for MultiWorkspace { this.activate_previous_workspace(window, cx); }, )) - .when(self.multi_workspace_enabled(cx), |this| { - this.on_action(cx.listener( - |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| { - this.toggle_sidebar(window, cx); - }, - )) - .on_action(cx.listener( - |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| { - this.focus_sidebar(window, cx); - }, - )) - }) - .when( - self.sidebar_open() && self.multi_workspace_enabled(cx), - |this| { - this.on_drag_move(cx.listener( - |this: &mut Self, e: &DragMoveEvent, _window, cx| { - if let Some(sidebar) = &this.sidebar { - let new_width = e.event.position.x; - sidebar.set_width(Some(new_width), cx); - } - }, - )) - .children(sidebar) - }, - ) .child( div() .flex() @@ -837,98 +569,9 @@ impl Render for MultiWorkspace { window, cx, Tiling { - left: multi_workspace_enabled && self.sidebar_open, + left: false, ..Tiling::default() }, ) } } - -#[cfg(test)] -mod tests { - use super::*; - use fs::FakeFs; - use gpui::TestAppContext; - use settings::SettingsStore; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - DisableAiSettings::register(cx); - cx.update_flags(false, vec!["agent-v2".into()]); - }); - } - - #[gpui::test] - async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - - multi_workspace.read_with(cx, |mw, cx| { - assert!(mw.multi_workspace_enabled(cx)); - }); - - multi_workspace.update_in(cx, |mw, _window, cx| { - mw.open_sidebar(cx); - assert!(mw.is_sidebar_open()); - }); - - cx.update(|_window, cx| { - DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); - }); - cx.run_until_parked(); - - multi_workspace.read_with(cx, |mw, cx| { - assert!( - !mw.is_sidebar_open(), - "Sidebar should be closed when disable_ai is true" - ); - assert!( - !mw.multi_workspace_enabled(cx), - "Multi-workspace should be disabled when disable_ai is true" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.toggle_sidebar(window, cx); - }); - multi_workspace.read_with(cx, |mw, _cx| { - assert!( - !mw.is_sidebar_open(), - "Sidebar should remain closed when toggled with disable_ai true" - ); - }); - - cx.update(|_window, cx| { - DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); - }); - cx.run_until_parked(); - - multi_workspace.read_with(cx, |mw, cx| { - assert!( - mw.multi_workspace_enabled(cx), - "Multi-workspace should be enabled after re-enabling AI" - ); - assert!( - !mw.is_sidebar_open(), - "Sidebar should still be closed after re-enabling AI (not auto-opened)" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.toggle_sidebar(window, cx); - }); - multi_workspace.read_with(cx, |mw, _cx| { - assert!( - mw.is_sidebar_open(), - "Sidebar should open when toggled after re-enabling AI" - ); - }); - } -} diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 492b7a8f385730feaa06dfe3b5e8b4cc0a20bb59..9f0b035049ebb5bfbeef7211acee9ced5288bb47 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -341,6 +341,7 @@ pub fn read_serialized_multi_workspaces( .map(read_multi_workspace_state) .unwrap_or_default(); model::SerializedMultiWorkspace { + id: window_id.map(|id| model::MultiWorkspaceId(id.as_u64())), workspaces: group, state, } @@ -3877,7 +3878,6 @@ mod tests { window_10, MultiWorkspaceState { active_workspace_id: Some(WorkspaceId(2)), - sidebar_open: true, }, ) .await; @@ -3886,7 +3886,6 @@ mod tests { window_20, MultiWorkspaceState { active_workspace_id: Some(WorkspaceId(3)), - sidebar_open: false, }, ) .await; @@ -3924,23 +3923,20 @@ mod tests { // Should produce 3 groups: window 10, window 20, and the orphan. assert_eq!(results.len(), 3); - // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open. + // Window 10 group: 2 workspaces, active_workspace_id = 2. let group_10 = &results[0]; assert_eq!(group_10.workspaces.len(), 2); assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2))); - assert_eq!(group_10.state.sidebar_open, true); - // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed. + // Window 20 group: 1 workspace, active_workspace_id = 3. let group_20 = &results[1]; assert_eq!(group_20.workspaces.len(), 1); assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3))); - assert_eq!(group_20.state.sidebar_open, false); // Orphan group: no window_id, so state is default. let group_none = &results[2]; assert_eq!(group_none.workspaces.len(), 1); assert_eq!(group_none.state.active_workspace_id, None); - assert_eq!(group_none.state.sidebar_open, false); } #[gpui::test] diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 0971ebd0ddc9265ccf9ea10da7745ba59914db30..c5251f20be9313a50f2256c54823d8839bdfe7fd 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -63,18 +63,19 @@ pub struct SessionWorkspace { #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct MultiWorkspaceState { pub active_workspace_id: Option, - pub sidebar_open: bool, } -/// The serialized state of a single MultiWorkspace window from a previous session: -/// all workspaces that shared the window, which one was active, and whether the -/// sidebar was open. +/// The serialized state of a single MultiWorkspace window from a previous session. #[derive(Debug, Clone)] pub struct SerializedMultiWorkspace { + pub id: Option, pub workspaces: Vec, pub state: MultiWorkspaceState, } +#[derive(Debug, Clone, Copy)] +pub struct MultiWorkspaceId(pub u64); + #[derive(Debug, PartialEq, Clone)] pub(crate) struct SerializedWorkspace { pub(crate) id: WorkspaceId, diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 5e0b8a7f6eabbd652f1f429342a837aa0b43e6d2..9087cbba42b054c1b247bdf3d9402688de4b7add 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -34,7 +34,6 @@ pub struct StatusBar { right_items: Vec>, active_pane: Entity, _observe_active_pane: Subscription, - workspace_sidebar_open: bool, } impl Render for StatusBar { @@ -52,10 +51,9 @@ impl Render for StatusBar { .when(!(tiling.bottom || tiling.right), |el| { el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) }) - .when( - !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open, - |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING), - ) + .when(!(tiling.bottom || tiling.left), |el| { + el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) + }) // This border is to avoid a transparent gap in the rounded corners .mb(px(-1.)) .border_b(px(1.0)) @@ -91,17 +89,11 @@ impl StatusBar { _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| { this.update_active_pane_item(window, cx) }), - workspace_sidebar_open: false, }; this.update_active_pane_item(window, cx); this } - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); - } - pub fn add_left_item(&mut self, item: Entity, window: &mut Window, cx: &mut Context) where T: 'static + StatusItemView, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 90f05d07a3a87a53ca25a1dc15da7663a95984a8..b57b5028a4e5558b1f90c715463165ba68d914e3 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -28,8 +28,8 @@ pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, - NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, - SidebarHandle, ToggleWorkspaceSidebar, + NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, + SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, multi_workspace_enabled, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -80,8 +80,8 @@ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace}; pub use persistence::{ DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items, model::{ - DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, - SessionWorkspace, + DockStructure, ItemId, MultiWorkspaceId, SerializedMultiWorkspace, + SerializedWorkspaceLocation, SessionWorkspace, }, read_serialized_multi_workspaces, }; @@ -2154,12 +2154,6 @@ impl Workspace { &self.status_bar } - pub fn set_workspace_sidebar_open(&self, open: bool, cx: &mut App) { - self.status_bar.update(cx, |status_bar, cx| { - status_bar.set_workspace_sidebar_open(open, cx); - }); - } - pub fn status_bar_visible(&self, cx: &App) -> bool { StatusBarSettings::get_global(cx).show } @@ -8184,7 +8178,11 @@ pub async fn restore_multiworkspace( app_state: Arc, cx: &mut AsyncApp, ) -> anyhow::Result { - let SerializedMultiWorkspace { workspaces, state } = multi_workspace; + let SerializedMultiWorkspace { + workspaces, + state, + id: window_id, + } = multi_workspace; let mut group_iter = workspaces.into_iter(); let first = group_iter .next() @@ -8248,6 +8246,7 @@ pub async fn restore_multiworkspace( if let Some(target_id) = state.active_workspace_id { window_handle .update(cx, |multi_workspace, window, cx| { + multi_workspace.set_database_id(window_id); let target_index = multi_workspace .workspaces() .iter() @@ -8269,14 +8268,6 @@ pub async fn restore_multiworkspace( .ok(); } - if state.sidebar_open { - window_handle - .update(cx, |multi_workspace, _, cx| { - multi_workspace.open_sidebar(cx); - }) - .ok(); - } - window_handle .update(cx, |_, window, _cx| { window.activate_window(); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 9c0c892ad7105cc5be9b3dd548659aa1f12a7966..2f61121d9c0aeb80a77d36bc4836b33c63936584 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -182,7 +182,6 @@ settings.workspace = true settings_profile_selector.workspace = true settings_ui.workspace = true shellexpand.workspace = true -sidebar.workspace = true smol.workspace = true snippet_provider.workspace = true snippets_ui.workspace = true diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index ead16b911e3ccf9ebd1b9f54113cb01dca849e9d..37642b012edcd133dfe770a4c57c5404658582b5 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -103,8 +103,8 @@ use { feature_flags::FeatureFlagAppExt as _, git_ui::project_diff::ProjectDiff, gpui::{ - App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, VisualTestAppContext, - WindowBounds, WindowHandle, WindowOptions, point, px, size, + Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, + VisualTestAppContext, WindowBounds, WindowHandle, WindowOptions, point, px, size, }, image::RgbaImage, project_panel::ProjectPanel, @@ -2649,22 +2649,6 @@ fn run_multi_workspace_sidebar_visual_tests( cx.run_until_parked(); - // Create the sidebar and register it on the MultiWorkspace - let sidebar = multi_workspace_window - .update(cx, |_multi_workspace, window, cx| { - let multi_workspace_handle = cx.entity(); - cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) - }) - .context("Failed to create sidebar")?; - - multi_workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.register_sidebar(sidebar.clone(), window, cx); - }) - .context("Failed to register sidebar")?; - - cx.run_until_parked(); - // Save test threads to the ThreadStore for each workspace let save_tasks = multi_workspace_window .update(cx, |multi_workspace, _window, cx| { @@ -2742,8 +2726,8 @@ fn run_multi_workspace_sidebar_visual_tests( // Open the sidebar multi_workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.toggle_sidebar(window, cx); + .update(cx, |_multi_workspace, window, cx| { + window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx); }) .context("Failed to toggle sidebar")?; @@ -3181,24 +3165,10 @@ edition = "2021" cx.run_until_parked(); - // Create and register the workspace sidebar - let sidebar = workspace_window - .update(cx, |_multi_workspace, window, cx| { - let multi_workspace_handle = cx.entity(); - cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) - }) - .context("Failed to create sidebar")?; - - workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.register_sidebar(sidebar.clone(), window, cx); - }) - .context("Failed to register sidebar")?; - // Open the sidebar workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.toggle_sidebar(window, cx); + .update(cx, |_multi_workspace, window, cx| { + window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx); }) .context("Failed to toggle sidebar")?; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 6eee25e6faddae5fdaae7ac2704a10a979b30ce7..b64bcbf3ab9ab5e29fdd473a200c2367e3f6f777 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -68,7 +68,6 @@ use settings::{ initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content, update_settings_file, }; -use sidebar::Sidebar; use std::time::Duration; use std::{ borrow::Cow, @@ -389,20 +388,6 @@ pub fn initialize_workspace( }) .unwrap_or(true) }); - - let window_handle = window.window_handle(); - let multi_workspace_handle = cx.entity(); - cx.defer(move |cx| { - window_handle - .update(cx, |_, window, cx| { - let sidebar = - cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx)); - multi_workspace_handle.update(cx, |multi_workspace, cx| { - multi_workspace.register_sidebar(sidebar, window, cx); - }); - }) - .ok(); - }); }) .detach(); From c08fd438ecce6c75834bf884ae49cd3574f7e62a Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Wed, 11 Mar 2026 01:12:18 -0700 Subject: [PATCH 18/32] languages: Validate pylsp binary before returning from check_if_user_installed (#51034) Run `pylsp --version` via `delegate.try_exec()` in both branches of `PyLspAdapter::check_if_user_installed` before returning the binary. If execution fails (broken shebang, missing interpreter, etc.), log a warning and return None so the system falls through gracefully instead of surfacing an error dialog. This matches the existing validation pattern used by TyLspAdapter and RuffLspAdapter in their `fetch_server_binary` implementations. No idea if this closes an issue but it sure was annoying on a system where I deleted the pylsp environment I had. It surprised me too since I had pylsp disabled in settings. Release Notes: - Fixed detection of when `pylsp` is not installed properly on a user's system so that it doesn't get launched as an LSP when it doesn't exist. --- crates/languages/src/python.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 95bfc798414f5d3629e1ea46f54d14a7ed58a8d4..078db5ba027c4d089b7c2f62cbd7e8468e526171 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1846,6 +1846,17 @@ impl LspInstaller for PyLspAdapter { ) -> Option { if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await { let env = delegate.shell_env().await; + delegate + .try_exec(LanguageServerBinary { + path: pylsp_bin.clone(), + arguments: vec!["--version".into()], + env: Some(env.clone()), + }) + .await + .inspect_err(|err| { + log::warn!("failed to validate user-installed pylsp at {pylsp_bin:?}: {err:#}") + }) + .ok()?; Some(LanguageServerBinary { path: pylsp_bin, env: Some(env), @@ -1854,7 +1865,21 @@ impl LspInstaller for PyLspAdapter { } else { let toolchain = toolchain?; let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp"); - pylsp_path.exists().then(|| LanguageServerBinary { + if !pylsp_path.exists() { + return None; + } + delegate + .try_exec(LanguageServerBinary { + path: toolchain.path.to_string().into(), + arguments: vec![pylsp_path.clone().into(), "--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!("failed to validate toolchain pylsp at {pylsp_path:?}: {err:#}") + }) + .ok()?; + Some(LanguageServerBinary { path: toolchain.path.to_string().into(), arguments: vec![pylsp_path.into()], env: None, From deccb78ff1e9c37460da2ffd8708c5c49bb2db02 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 11 Mar 2026 09:49:04 +0100 Subject: [PATCH 19/32] agent_ui: Fix thread summarization not working (#51259) Release Notes: - N/A --- crates/agent_ui/src/connection_view.rs | 22 ++++++++++++++----- .../src/connection_view/thread_view.rs | 7 ++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index fd4ac66c05e380ddd3e1c3e2c196c5a397754c9d..3f1f1fb164693e0bb9e0b6d8883b97ab5539ba4f 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -3782,8 +3782,16 @@ pub(crate) mod tests { } impl Render for ThreadViewItem { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.0.clone().into_any_element() + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Render the title editor in the element tree too. In the real app + // it is part of the agent panel + let title_editor = self + .0 + .read(cx) + .active_thread() + .map(|t| t.read(cx).title_editor.clone()); + + v_flex().children(title_editor).child(self.0.clone()) } } @@ -6060,6 +6068,7 @@ pub(crate) mod tests { init_test(cx); let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + add_to_workspace(thread_view.clone(), cx); let active = active_thread(&thread_view, cx); let title_editor = cx.read(|cx| active.read(cx).title_editor.clone()); @@ -6069,9 +6078,12 @@ pub(crate) mod tests { assert!(!editor.read_only(cx)); }); - title_editor.update_in(cx, |editor, window, cx| { - editor.set_text("My Custom Title", window, cx); - }); + cx.focus(&thread_view); + cx.focus(&title_editor); + + cx.dispatch_action(editor::actions::DeleteLine); + cx.simulate_input("My Custom Title"); + cx.run_until_parked(); title_editor.read_with(cx, |editor, cx| { diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 806b2c9c397de1c729164b5f859ceae4b7f6231f..771d80f08306838e756a2ea3dd8aa4b378cfd402 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -1464,6 +1464,13 @@ impl ThreadView { match event { EditorEvent::BufferEdited => { + // We only want to set the title if the user has actively edited + // it. If the title editor is not focused, we programmatically + // changed the text, so we don't want to set the title again. + if !title_editor.read(cx).is_focused(window) { + return; + } + let new_title = title_editor.read(cx).text(cx); thread.update(cx, |thread, cx| { thread From 2c59990135f299a976b5d9cd4664b787f04bc451 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 11 Mar 2026 12:51:54 +0200 Subject: [PATCH 20/32] Do not load runnables and diagnostics in git panel (#51270) Release Notes: - N/A --------- Co-authored-by: Jakub Konka --- crates/editor/src/document_colors.rs | 2 +- crates/editor/src/document_symbols.rs | 2 +- crates/editor/src/editor.rs | 56 ++++++++++++---------- crates/editor/src/folding_ranges.rs | 2 +- crates/editor/src/inlays/inlay_hints.rs | 2 +- crates/editor/src/linked_editing_ranges.rs | 2 +- crates/editor/src/semantic_tokens.rs | 2 +- crates/editor/src/split.rs | 3 ++ 8 files changed, 40 insertions(+), 31 deletions(-) diff --git a/crates/editor/src/document_colors.rs b/crates/editor/src/document_colors.rs index 579414c7f91c6b2770951a2439599abc4000b27c..a38a0527f0641ef2d622b2f33fa1e932080ad7b5 100644 --- a/crates/editor/src/document_colors.rs +++ b/crates/editor/src/document_colors.rs @@ -145,7 +145,7 @@ impl Editor { _: &Window, cx: &mut Context, ) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } let Some(project) = self.project.as_ref() else { diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index b73c1abbfb9bfec86093eed72082232275388faf..0228bbd917ad96b94778b2fc01d3a66e81224296 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -147,7 +147,7 @@ impl Editor { for_buffer: Option, cx: &mut Context, ) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } let Some(project) = self.project.clone() else { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index aabf16d2b64846388b6b1c0903e280e9f465a41d..a08ac3bbc466d159ce81a7aa3bebf82599914a0b 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7733,7 +7733,7 @@ impl Editor { #[ztracing::instrument(skip_all)] fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context) { - if !self.mode.is_full() { + if !self.lsp_data_enabled() { return; } let cursor = self.selections.newest_anchor().head(); @@ -17154,13 +17154,17 @@ impl Editor { } fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { - if !EditorSettings::get_global(cx).gutter.runnables || !self.enable_runnables { + if !self.mode().is_full() + || !EditorSettings::get_global(cx).gutter.runnables + || !self.enable_runnables + { self.clear_tasks(); return Task::ready(()); } let project = self.project().map(Entity::downgrade); let task_sources = self.lsp_task_sources(cx); let multi_buffer = self.buffer.downgrade(); + let lsp_data_enabled = self.lsp_data_enabled(); cx.spawn_in(window, async move |editor, cx| { cx.background_executor().timer(UPDATE_DEBOUNCE).await; let Some(project) = project.and_then(|p| p.upgrade()) else { @@ -17176,20 +17180,27 @@ impl Editor { if hide_runnables { return; } - let new_rows = - cx.background_spawn({ + let new_rows = cx + .background_spawn({ let snapshot = display_snapshot.clone(); async move { - Self::fetch_runnable_ranges(&snapshot, Anchor::min()..Anchor::max()) + snapshot + .buffer_snapshot() + .runnable_ranges(Anchor::min()..Anchor::max()) + .collect() } }) - .await; - let Ok(lsp_tasks) = - cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx)) - else { - return; + .await; + let lsp_tasks = if lsp_data_enabled { + let Ok(lsp_tasks) = + cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx)) + else { + return; + }; + lsp_tasks.await + } else { + Vec::new() }; - let lsp_tasks = lsp_tasks.await; let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { lsp_tasks @@ -17270,12 +17281,6 @@ impl Editor { .ok(); }) } - fn fetch_runnable_ranges( - snapshot: &DisplaySnapshot, - range: Range, - ) -> Vec<(Range, language::RunnableRange)> { - snapshot.buffer_snapshot().runnable_ranges(range).collect() - } fn runnable_rows( project: Entity, @@ -19607,7 +19612,7 @@ impl Editor { } pub fn diagnostics_enabled(&self) -> bool { - self.diagnostics_enabled && self.mode.is_full() + self.diagnostics_enabled && self.lsp_data_enabled() } pub fn inline_diagnostics_enabled(&self) -> bool { @@ -19771,10 +19776,7 @@ impl Editor { // `ActiveDiagnostic::All` is a special mode where editor's diagnostics are managed by the external view, // skip any LSP updates for it. - if self.active_diagnostics == ActiveDiagnostic::All - || !self.mode().is_full() - || !self.diagnostics_enabled() - { + if self.active_diagnostics == ActiveDiagnostic::All || !self.diagnostics_enabled() { return None; } let pull_diagnostics_settings = ProjectSettings::get_global(cx) @@ -25628,13 +25630,17 @@ impl Editor { } } + fn lsp_data_enabled(&self) -> bool { + self.enable_lsp_data && self.mode().is_full() + } + fn update_lsp_data( &mut self, for_buffer: Option, window: &mut Window, cx: &mut Context<'_, Self>, ) { - if !self.enable_lsp_data { + if !self.lsp_data_enabled() { return; } @@ -25648,7 +25654,7 @@ impl Editor { } fn register_visible_buffers(&mut self, cx: &mut Context) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } for (_, (visible_buffer, _, _)) in self.visible_excerpts(true, cx) { @@ -25657,7 +25663,7 @@ impl Editor { } fn register_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } diff --git a/crates/editor/src/folding_ranges.rs b/crates/editor/src/folding_ranges.rs index 593095b004792be2055b0dc2614d086f114acd5e..745fdcbe30a0aede4f364afd5c58958c74b3da79 100644 --- a/crates/editor/src/folding_ranges.rs +++ b/crates/editor/src/folding_ranges.rs @@ -13,7 +13,7 @@ impl Editor { _window: &Window, cx: &mut Context, ) { - if !self.mode().is_full() || !self.use_document_folding_ranges { + if !self.lsp_data_enabled() || !self.use_document_folding_ranges { return; } let Some(project) = self.project.clone() else { diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 0b3f6bda09c2cf86b994682e2ed89c2614d72737..62eb35f1ac85227c9b52737660da0d1834e1bbfa 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -292,7 +292,7 @@ impl Editor { reason: InlayHintRefreshReason, cx: &mut Context, ) { - if !self.mode().is_full() || self.inlay_hints.is_none() { + if !self.lsp_data_enabled() || self.inlay_hints.is_none() { return; } let Some(semantics_provider) = self.semantics_provider() else { diff --git a/crates/editor/src/linked_editing_ranges.rs b/crates/editor/src/linked_editing_ranges.rs index 34fc1e97df2b01cb3e35b95ec90d0c8d31f5790a..ccd0e64bd850f6ce84e225fe77f1c0a0d5385dc1 100644 --- a/crates/editor/src/linked_editing_ranges.rs +++ b/crates/editor/src/linked_editing_ranges.rs @@ -50,7 +50,7 @@ pub(super) fn refresh_linked_ranges( window: &mut Window, cx: &mut Context, ) -> Option<()> { - if !editor.mode().is_full() || editor.pending_rename.is_some() { + if !editor.lsp_data_enabled() || editor.pending_rename.is_some() { return None; } let project = editor.project()?.downgrade(); diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index 31a573f04787e3759a6a21ec15f36ec148a80f30..e95b20aed5a6655d6ae4ccd2c6658cfcfecc2ea4 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -119,7 +119,7 @@ impl Editor { for_server: Option, cx: &mut Context, ) { - if !self.mode().is_full() || !self.semantic_token_state.enabled() { + if !self.lsp_data_enabled() || !self.semantic_token_state.enabled() { self.invalidate_semantic_tokens(None); self.display_map.update(cx, |display_map, _| { match Arc::get_mut(&mut display_map.semantic_token_highlights) { diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 4e5f8ebf2793f6807e0a9108e12c276a7ab45427..877f388fc3b783202cb29f8ca063446635e4277a 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -446,6 +446,9 @@ impl SplittableEditor { let mut editor = Editor::for_multibuffer(rhs_multibuffer.clone(), Some(project.clone()), window, cx); editor.set_expand_all_diff_hunks(cx); + editor.disable_runnables(); + editor.disable_diagnostics(cx); + editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx); editor }); // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs From e5fb57c8afc76731df15be3ae5510fb1d5bce965 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 11 Mar 2026 11:53:22 +0100 Subject: [PATCH 21/32] extension_rollout: Add incremental rollout (#51264) This will allow us to test changes against just a subset of extensions. Another advantage is that extension workflows will be pinned, which allows for easier debugging and better predictability. Release Notes: - N/A --- .github/workflows/bump_patch_version.yml | 2 +- .../workflows/extension_workflow_rollout.yml | 145 ++++++---- tooling/xtask/src/tasks/workflows.rs | 83 +++++- .../workflows/extension_workflow_rollout.rs | 263 ++++++++++++------ .../workflows/extensions/bump_version.rs | 8 +- .../tasks/workflows/extensions/run_tests.rs | 9 +- tooling/xtask/src/tasks/workflows/steps.rs | 20 +- 7 files changed, 356 insertions(+), 174 deletions(-) diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 480d8b0ada98e859d2e72b49a39805ffe8f72b25..62540321ed755f2fd3879a7ddfc3a37237d8e7de 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -23,8 +23,8 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false - token: ${{ steps.get-app-token.outputs.token }} ref: ${{ inputs.branch }} + token: ${{ steps.get-app-token.outputs.token }} - name: bump_patch_version::run_bump_patch_version::bump_patch_version run: | channel="$(cat crates/zed/RELEASE_CHANNEL)" diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index 9bfac06d4527985553ba3d04e64c656ee5bf85e4..cbb813d91749bf3843b64372f12e50f6a3c3e785 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -4,12 +4,57 @@ name: extension_workflow_rollout env: CARGO_TERM_COLOR: always on: - workflow_dispatch: {} + workflow_dispatch: + inputs: + filter-repos: + description: Comma-separated list of repository names to rollout to. Leave empty for all repos. + type: string + default: '' + change-description: + description: Description for the changes to be expected with this rollout + type: string + default: '' jobs: fetch_extension_repos: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' runs-on: namespace-profile-2x4-ubuntu-2404 steps: + - name: checkout_zed_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 0 + - id: prev-tag + name: extension_workflow_rollout::fetch_extension_repos::get_previous_tag_commit + run: | + PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "") + if [ -z "$PREV_COMMIT" ]; then + echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes." + exit 1 + fi + echo "Found previous rollout at commit: $PREV_COMMIT" + echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" + - id: calc-changes + name: extension_workflow_rollout::fetch_extension_repos::get_removed_files + run: | + for workflow_type in "ci" "shared"; do + if [ "$workflow_type" = "ci" ]; then + WORKFLOW_DIR="extensions/workflows" + else + WORKFLOW_DIR="extensions/workflows/shared" + fi + + REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ + awk '/^D/ { print $2 } /^R/ { print $2 }' | \ + xargs -I{} basename {} 2>/dev/null | \ + tr '\n' ' ' || echo "") + REMOVED=$(echo "$REMOVED" | xargs) + + echo "Removed files for $workflow_type: $REMOVED" + echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT" + done + env: + PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }} - id: list-repos name: extension_workflow_rollout::fetch_extension_repos::get_repositories uses: actions/github-script@v7 @@ -21,16 +66,42 @@ jobs: per_page: 100, }); - const filteredRepos = repos + let filteredRepos = repos .filter(repo => !repo.archived) .map(repo => repo.name); + const filterInput = `${{ inputs.filter-repos }}`.trim(); + if (filterInput.length > 0) { + const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0); + filteredRepos = filteredRepos.filter(name => allowedNames.includes(name)); + console.log(`Filter applied. Matched ${filteredRepos.length} repos from ${allowedNames.length} requested.`); + } + console.log(`Found ${filteredRepos.length} extension repos`); return filteredRepos; result-encoding: json + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + path: ~/.rustup + - name: extension_workflow_rollout::fetch_extension_repos::generate_workflow_files + run: | + cargo xtask workflows "$COMMIT_SHA" + env: + COMMIT_SHA: ${{ github.sha }} + - name: extension_workflow_rollout::fetch_extension_repos::upload_workflow_files + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: extension-workflow-files + path: extensions/workflows/**/*.yml + if-no-files-found: error outputs: repos: ${{ steps.list-repos.outputs.result }} - timeout-minutes: 5 + prev_commit: ${{ steps.prev-tag.outputs.prev_commit }} + removed_ci: ${{ steps.calc-changes.outputs.removed_ci }} + removed_shared: ${{ steps.calc-changes.outputs.removed_shared }} + timeout-minutes: 10 rollout_workflows_to_extension: needs: - fetch_extension_repos @@ -53,59 +124,28 @@ jobs: permission-pull-requests: write permission-contents: write permission-workflows: write - - name: checkout_zed_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - with: - clean: false - fetch-depth: 0 - path: zed - name: checkout_extension_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false - token: ${{ steps.generate-token.outputs.token }} path: extension repository: zed-extensions/${{ matrix.repo }} - - id: prev-tag - name: extension_workflow_rollout::rollout_workflows_to_extension::get_previous_tag_commit - run: | - PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "") - if [ -z "$PREV_COMMIT" ]; then - echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes." - exit 1 - fi - echo "Found previous rollout at commit: $PREV_COMMIT" - echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" - working-directory: zed - - id: calc-changes - name: extension_workflow_rollout::rollout_workflows_to_extension::get_removed_files + token: ${{ steps.generate-token.outputs.token }} + - name: extension_workflow_rollout::rollout_workflows_to_extension::download_workflow_files + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + with: + name: extension-workflow-files + path: workflow-files + - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files run: | + mkdir -p extension/.github/workflows + if [ "$MATRIX_REPO" = "workflows" ]; then - WORKFLOW_DIR="extensions/workflows" + REMOVED_FILES="$REMOVED_CI" else - WORKFLOW_DIR="extensions/workflows/shared" + REMOVED_FILES="$REMOVED_SHARED" fi - echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR" - - # Get deleted files (status D) and renamed files (status R - old name needs removal) - # Using -M to detect renames, then extracting files that are gone from their original location - REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ - awk '/^D/ { print $2 } /^R/ { print $2 }' | \ - xargs -I{} basename {} 2>/dev/null | \ - tr '\n' ' ' || echo "") - - REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs) - - echo "Files to remove: $REMOVED_FILES" - echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT" - env: - PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }} - MATRIX_REPO: ${{ matrix.repo }} - working-directory: zed - - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files - run: | - mkdir -p extension/.github/workflows cd extension/.github/workflows if [ -n "$REMOVED_FILES" ]; then @@ -119,18 +159,18 @@ jobs: cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp zed/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/extensions/workflows/*.yml extension/.github/workflows/ else - cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/extensions/workflows/shared/*.yml extension/.github/workflows/ fi env: - REMOVED_FILES: ${{ steps.calc-changes.outputs.removed_files }} + REMOVED_CI: ${{ needs.fetch_extension_repos.outputs.removed_ci }} + REMOVED_SHARED: ${{ needs.fetch_extension_repos.outputs.removed_shared }} MATRIX_REPO: ${{ matrix.repo }} - id: short-sha name: extension_workflow_rollout::rollout_workflows_to_extension::get_short_sha run: | - echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" - working-directory: zed + echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" - id: create-pr name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request uses: peter-evans/create-pull-request@v7 @@ -140,6 +180,8 @@ jobs: body: | This PR updates the CI workflow files from the main Zed repository based on the commit zed-industries/zed@${{ github.sha }} + + ${{ inputs.change-description }} commit-message: Update CI workflows to `${{ steps.short-sha.outputs.sha_short }}` branch: update-workflows committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> @@ -151,16 +193,17 @@ jobs: - name: extension_workflow_rollout::rollout_workflows_to_extension::enable_auto_merge run: | if [ -n "$PR_NUMBER" ]; then - cd extension gh pr merge "$PR_NUMBER" --auto --squash fi env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }} + working-directory: extension timeout-minutes: 10 create_rollout_tag: needs: - rollout_workflows_to_extension + if: inputs.filter-repos == '' runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 9151b9c671ef42e3dc54661f80438a4e31aff1e9..26596c9401c1d3c500a8c1cb18083d525c934e20 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -29,38 +29,99 @@ mod runners; mod steps; mod vars; +#[derive(Clone)] +pub(crate) struct GitSha(String); + +impl AsRef for GitSha { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[allow( + clippy::disallowed_methods, + reason = "This runs only in a CLI environment" +)] +fn parse_ref(value: &str) -> Result { + const GIT_SHA_LENGTH: usize = 40; + (value.len() == GIT_SHA_LENGTH) + .then_some(value) + .ok_or_else(|| { + format!( + "Git SHA has wrong length! \ + Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.", + len = value.len() + ) + }) + .and_then(|value| { + let mut tmp = [0; 4]; + value + .chars() + .all(|char| u16::from_str_radix(char.encode_utf8(&mut tmp), 16).is_ok()).then_some(value) + .ok_or_else(|| "Not a valid Git SHA".to_owned()) + }) + .and_then(|sha| { + std::process::Command::new("git") + .args([ + "rev-parse", + "--quiet", + "--verify", + &format!("{sha}^{{commit}}") + ]) + .output() + .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned()) + .and_then(|output| + output + .status.success() + .then_some(sha) + .ok_or_else(|| format!("SHA {sha} is not a valid Git SHA within this repository!"))) + }).map(|sha| GitSha(sha.to_owned())) +} + #[derive(Parser)] -pub struct GenerateWorkflowArgs {} +pub(crate) struct GenerateWorkflowArgs { + #[arg(value_parser = parse_ref)] + /// The Git SHA to use when invoking this + pub(crate) sha: Option, +} + +enum WorkflowSource { + Contextless(fn() -> Workflow), + WithContext(fn(&GenerateWorkflowArgs) -> Workflow), +} struct WorkflowFile { - source: fn() -> Workflow, + source: WorkflowSource, r#type: WorkflowType, } impl WorkflowFile { fn zed(f: fn() -> Workflow) -> WorkflowFile { WorkflowFile { - source: f, + source: WorkflowSource::Contextless(f), r#type: WorkflowType::Zed, } } - fn extension(f: fn() -> Workflow) -> WorkflowFile { + fn extension(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile { WorkflowFile { - source: f, + source: WorkflowSource::WithContext(f), r#type: WorkflowType::ExtensionCi, } } - fn extension_shared(f: fn() -> Workflow) -> WorkflowFile { + fn extension_shared(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile { WorkflowFile { - source: f, + source: WorkflowSource::WithContext(f), r#type: WorkflowType::ExtensionsShared, } } - fn generate_file(&self) -> Result<()> { - let workflow = (self.source)(); + fn generate_file(&self, workflow_args: &GenerateWorkflowArgs) -> Result<()> { + let workflow = match &self.source { + WorkflowSource::Contextless(f) => f(), + WorkflowSource::WithContext(f) => f(workflow_args), + }; let workflow_folder = self.r#type.folder_path(); fs::create_dir_all(&workflow_folder).with_context(|| { @@ -124,7 +185,7 @@ impl WorkflowType { } } -pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { +pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { if !Path::new("crates/zed/").is_dir() { anyhow::bail!("xtask workflows must be ran from the project root"); } @@ -154,7 +215,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { ]; for workflow_file in workflows { - workflow_file.generate_file()?; + workflow_file.generate_file(&args)?; } workflow_checks::validate(Default::default()) diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 6f03ad1521850fb24c5bad7265ebf913228c5077..91154c91061fd2e8a51e60704eca0f9b0b94c900 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -6,46 +6,72 @@ use indoc::indoc; use serde_json::json; use crate::tasks::workflows::steps::CheckoutStep; +use crate::tasks::workflows::steps::cache_rust_dependencies_namespace; +use crate::tasks::workflows::vars::JobOutput; use crate::tasks::workflows::{ extension_bump::{RepositoryTarget, generate_token}, runners, steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named}, - vars::{self, StepOutput}, + vars::{self, StepOutput, WorkflowInput}, }; const ROLLOUT_TAG_NAME: &str = "extension-workflows"; +const WORKFLOW_ARTIFACT_NAME: &str = "extension-workflow-files"; pub(crate) fn extension_workflow_rollout() -> Workflow { - let fetch_repos = fetch_extension_repos(); - let rollout_workflows = rollout_workflows_to_extension(&fetch_repos); - let create_tag = create_rollout_tag(&rollout_workflows); + let filter_repos_input = WorkflowInput::string("filter-repos", Some(String::new())) + .description( + "Comma-separated list of repository names to rollout to. Leave empty for all repos.", + ); + let extra_context_input = WorkflowInput::string("change-description", Some(String::new())) + .description("Description for the changes to be expected with this rollout"); + + let (fetch_repos, removed_ci, removed_shared) = fetch_extension_repos(&filter_repos_input); + let rollout_workflows = rollout_workflows_to_extension( + &fetch_repos, + removed_ci, + removed_shared, + &extra_context_input, + ); + let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input); named::workflow() - .on(Event::default().workflow_dispatch(WorkflowDispatch::default())) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(filter_repos_input.name, filter_repos_input.input()) + .add_input(extra_context_input.name, extra_context_input.input()), + )) .add_env(("CARGO_TERM_COLOR", "always")) .add_job(fetch_repos.name, fetch_repos.job) .add_job(rollout_workflows.name, rollout_workflows.job) .add_job(create_tag.name, create_tag.job) } -fn fetch_extension_repos() -> NamedJob { - fn get_repositories() -> (Step, StepOutput) { +fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) { + fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step, StepOutput) { let step = named::uses("actions", "github-script", "v7") .id("list-repos") .add_with(( "script", - indoc::indoc! {r#" - const repos = await github.paginate(github.rest.repos.listForOrg, { + formatdoc! {r#" + const repos = await github.paginate(github.rest.repos.listForOrg, {{ org: 'zed-extensions', type: 'public', per_page: 100, - }); + }}); - const filteredRepos = repos + let filteredRepos = repos .filter(repo => !repo.archived) .map(repo => repo.name); - console.log(`Found ${filteredRepos.length} extension repos`); + const filterInput = `{filter_repos_input}`.trim(); + if (filterInput.length > 0) {{ + const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0); + filteredRepos = filteredRepos.filter(name => allowedNames.includes(name)); + console.log(`Filter applied. Matched ${{filteredRepos.length}} repos from ${{allowedNames.length}} requested.`); + }} + + console.log(`Found ${{filteredRepos.length}} extension repos`); return filteredRepos; "#}, )) @@ -56,36 +82,12 @@ fn fetch_extension_repos() -> NamedJob { (step, filtered_repos) } - let (get_org_repositories, list_repos_output) = get_repositories(); - - let job = Job::default() - .cond(Expression::new(format!( - "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'" - ))) - .runs_on(runners::LINUX_SMALL) - .timeout_minutes(5u32) - .outputs([("repos".to_owned(), list_repos_output.to_string())]) - .add_step(get_org_repositories); - - named::job(job) -} - -fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { fn checkout_zed_repo() -> CheckoutStep { steps::checkout_repo() .with_full_history() - .with_path("zed") .with_custom_name("checkout_zed_repo") } - fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep { - steps::checkout_repo() - .with_custom_name("checkout_extension_repo") - .with_token(token) - .with_repository("zed-extensions/${{ matrix.repo }}") - .with_path("extension") - } - fn get_previous_tag_commit() -> (Step, StepOutput) { let step = named::bash(formatdoc! {r#" PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "") @@ -96,49 +98,126 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { echo "Found previous rollout at commit: $PREV_COMMIT" echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" "#}) - .id("prev-tag") - .working_directory("zed"); + .id("prev-tag"); let step_output = StepOutput::new(&step, "prev_commit"); (step, step_output) } - fn get_removed_files(prev_commit: &StepOutput) -> (Step, StepOutput) { - let step = named::bash(indoc::indoc! {r#" - if [ "$MATRIX_REPO" = "workflows" ]; then - WORKFLOW_DIR="extensions/workflows" - else - WORKFLOW_DIR="extensions/workflows/shared" - fi - - echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR" + fn get_removed_files(prev_commit: &StepOutput) -> (Step, StepOutput, StepOutput) { + let step = named::bash(indoc! {r#" + for workflow_type in "ci" "shared"; do + if [ "$workflow_type" = "ci" ]; then + WORKFLOW_DIR="extensions/workflows" + else + WORKFLOW_DIR="extensions/workflows/shared" + fi + + REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ + awk '/^D/ { print $2 } /^R/ { print $2 }' | \ + xargs -I{} basename {} 2>/dev/null | \ + tr '\n' ' ' || echo "") + REMOVED=$(echo "$REMOVED" | xargs) + + echo "Removed files for $workflow_type: $REMOVED" + echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT" + done + "#}) + .id("calc-changes") + .add_env(("PREV_COMMIT", prev_commit.to_string())); - # Get deleted files (status D) and renamed files (status R - old name needs removal) - # Using -M to detect renames, then extracting files that are gone from their original location - REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ - awk '/^D/ { print $2 } /^R/ { print $2 }' | \ - xargs -I{} basename {} 2>/dev/null | \ - tr '\n' ' ' || echo "") + let removed_ci = StepOutput::new(&step, "removed_ci"); + let removed_shared = StepOutput::new(&step, "removed_shared"); - REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs) + (step, removed_ci, removed_shared) + } - echo "Files to remove: $REMOVED_FILES" - echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT" + fn generate_workflow_files() -> Step { + named::bash(indoc! {r#" + cargo xtask workflows "$COMMIT_SHA" "#}) - .id("calc-changes") - .working_directory("zed") - .add_env(("PREV_COMMIT", prev_commit.to_string())) - .add_env(("MATRIX_REPO", "${{ matrix.repo }}")); + .add_env(("COMMIT_SHA", "${{ github.sha }}")) + } - let removed_files = StepOutput::new(&step, "removed_files"); + fn upload_workflow_files() -> Step { + named::uses( + "actions", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 + ) + .add_with(("name", WORKFLOW_ARTIFACT_NAME)) + .add_with(("path", "extensions/workflows/**/*.yml")) + .add_with(("if-no-files-found", "error")) + } - (step, removed_files) + let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input); + let (get_prev_tag, prev_commit) = get_previous_tag_commit(); + let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit); + + let job = Job::default() + .cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'" + ))) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(10u32) + .outputs([ + ("repos".to_owned(), list_repos_output.to_string()), + ("prev_commit".to_owned(), prev_commit.to_string()), + ("removed_ci".to_owned(), removed_ci.to_string()), + ("removed_shared".to_owned(), removed_shared.to_string()), + ]) + .add_step(checkout_zed_repo()) + .add_step(get_prev_tag) + .add_step(calc_changes) + .add_step(get_org_repositories) + .add_step(cache_rust_dependencies_namespace()) + .add_step(generate_workflow_files()) + .add_step(upload_workflow_files()); + + let job = named::job(job); + let (removed_ci, removed_shared) = ( + removed_ci.as_job_output(&job), + removed_shared.as_job_output(&job), + ); + + (job, removed_ci, removed_shared) +} + +fn rollout_workflows_to_extension( + fetch_repos_job: &NamedJob, + removed_ci: JobOutput, + removed_shared: JobOutput, + extra_context_input: &WorkflowInput, +) -> NamedJob { + fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep { + steps::checkout_repo() + .with_custom_name("checkout_extension_repo") + .with_token(token) + .with_repository("zed-extensions/${{ matrix.repo }}") + .with_path("extension") + } + + fn download_workflow_files() -> Step { + named::uses( + "actions", + "download-artifact", + "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0 + ) + .add_with(("name", WORKFLOW_ARTIFACT_NAME)) + .add_with(("path", "workflow-files")) } - fn sync_workflow_files(removed_files: &StepOutput) -> Step { - named::bash(indoc::indoc! {r#" + fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step { + named::bash(indoc! {r#" mkdir -p extension/.github/workflows + + if [ "$MATRIX_REPO" = "workflows" ]; then + REMOVED_FILES="$REMOVED_CI" + else + REMOVED_FILES="$REMOVED_SHARED" + fi + cd extension/.github/workflows if [ -n "$REMOVED_FILES" ]; then @@ -152,40 +231,46 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp zed/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/extensions/workflows/*.yml extension/.github/workflows/ else - cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/extensions/workflows/shared/*.yml extension/.github/workflows/ fi "#}) - .add_env(("REMOVED_FILES", removed_files.to_string())) + .add_env(("REMOVED_CI", removed_ci)) + .add_env(("REMOVED_SHARED", removed_shared)) .add_env(("MATRIX_REPO", "${{ matrix.repo }}")) } fn get_short_sha() -> (Step, StepOutput) { - let step = named::bash(indoc::indoc! {r#" - echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" + let step = named::bash(indoc! {r#" + echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" "#}) - .id("short-sha") - .working_directory("zed"); + .id("short-sha"); let step_output = StepOutput::new(&step, "sha_short"); (step, step_output) } - fn create_pull_request(token: &StepOutput, short_sha: &StepOutput) -> Step { + fn create_pull_request( + token: &StepOutput, + short_sha: &StepOutput, + context_input: &WorkflowInput, + ) -> Step { let title = format!("Update CI workflows to `{short_sha}`"); + let body = formatdoc! {r#" + This PR updates the CI workflow files from the main Zed repository + based on the commit zed-industries/zed@${{{{ github.sha }}}} + + {context_input} + "#, + }; + named::uses("peter-evans", "create-pull-request", "v7") .add_with(("path", "extension")) .add_with(("title", title.clone())) - .add_with(( - "body", - indoc::indoc! {r#" - This PR updates the CI workflow files from the main Zed repository - based on the commit zed-industries/zed@${{ github.sha }} - "#}, - )) + .add_with(("body", body)) .add_with(("commit-message", title)) .add_with(("branch", "update-workflows")) .add_with(( @@ -204,12 +289,12 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { } fn enable_auto_merge(token: &StepOutput) -> Step { - named::bash(indoc::indoc! {r#" + named::bash(indoc! {r#" if [ -n "$PR_NUMBER" ]; then - cd extension gh pr merge "$PR_NUMBER" --auto --squash fi "#}) + .working_directory("extension") .add_env(("GH_TOKEN", token.to_string())) .add_env(( "PR_NUMBER", @@ -228,8 +313,6 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { ]), ), ); - let (get_prev_tag, prev_commit) = get_previous_tag_commit(); - let (calc_changes, removed_files) = get_removed_files(&prev_commit); let (calculate_short_sha, short_sha) = get_short_sha(); let job = Job::default() @@ -249,19 +332,17 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { })), ) .add_step(authenticate) - .add_step(checkout_zed_repo()) .add_step(checkout_extension_repo(&token)) - .add_step(get_prev_tag) - .add_step(calc_changes) - .add_step(sync_workflow_files(&removed_files)) + .add_step(download_workflow_files()) + .add_step(sync_workflow_files(removed_ci, removed_shared)) .add_step(calculate_short_sha) - .add_step(create_pull_request(&token, &short_sha)) + .add_step(create_pull_request(&token, &short_sha, extra_context_input)) .add_step(enable_auto_merge(&token)); named::job(job) } -fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob { +fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob { fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep { steps::checkout_repo().with_full_history().with_token(token) } @@ -297,6 +378,10 @@ fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob { let job = Job::default() .needs([rollout_job.name.clone()]) + .cond(Expression::new(format!( + "{filter_repos} == ''", + filter_repos = filter_repos_input.expr(), + ))) .runs_on(runners::LINUX_SMALL) .timeout_minutes(1u32) .add_step(authenticate) diff --git a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs index 2d82f1351f21645a77b1d13e158bd4142dbec069..4dc2560e2bea489566fb8eb5ad5d04701835de29 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs @@ -5,17 +5,18 @@ use gh_workflow::{ use indoc::indoc; use crate::tasks::workflows::{ + GenerateWorkflowArgs, GitSha, extensions::WithAppSecrets, runners, steps::{CommonJobConditions, NamedJob, named}, vars::{JobOutput, StepOutput, one_workflow_per_non_main_branch_and_token}, }; -pub(crate) fn bump_version() -> Workflow { +pub(crate) fn bump_version(args: &GenerateWorkflowArgs) -> Workflow { let (determine_bump_type, bump_type) = determine_bump_type(); let bump_type = bump_type.as_job_output(&determine_bump_type); - let call_bump_version = call_bump_version(&determine_bump_type, bump_type); + let call_bump_version = call_bump_version(args.sha.as_ref(), &determine_bump_type, bump_type); named::workflow() .on(Event::default() @@ -32,6 +33,7 @@ pub(crate) fn bump_version() -> Workflow { } pub(crate) fn call_bump_version( + target_ref: Option<&GitSha>, depending_job: &NamedJob, bump_type: JobOutput, ) -> NamedJob { @@ -51,7 +53,7 @@ pub(crate) fn call_bump_version( "zed-industries", "zed", ".github/workflows/extension_bump.yml", - "main", + target_ref.map_or("main", AsRef::as_ref), ) .add_need(depending_job.name.clone()) .with( diff --git a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs index 0c0ca696612fa57903f35c0ea69404f5dc7d1fe0..ae8000c15cad3a206b9c02f8bc389a369f4df096 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs @@ -1,12 +1,13 @@ use gh_workflow::{Event, Job, Level, Permissions, PullRequest, Push, UsesJob, Workflow}; use crate::tasks::workflows::{ + GenerateWorkflowArgs, GitSha, steps::{NamedJob, named}, vars::one_workflow_per_non_main_branch_and_token, }; -pub(crate) fn run_tests() -> Workflow { - let call_extension_tests = call_extension_tests(); +pub(crate) fn run_tests(args: &GenerateWorkflowArgs) -> Workflow { + let call_extension_tests = call_extension_tests(args.sha.as_ref()); named::workflow() .on(Event::default() .pull_request(PullRequest::default().add_branch("**")) @@ -15,14 +16,14 @@ pub(crate) fn run_tests() -> Workflow { .add_job(call_extension_tests.name, call_extension_tests.job) } -pub(crate) fn call_extension_tests() -> NamedJob { +pub(crate) fn call_extension_tests(target_ref: Option<&GitSha>) -> NamedJob { let job = Job::default() .permissions(Permissions::default().contents(Level::Read)) .uses( "zed-industries", "zed", ".github/workflows/extension_tests.yml", - "main", + target_ref.map_or("main", AsRef::as_ref), ); named::job(job) diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 4d17be81322277d0093de5d547bf4f0849e38dc3..6bede217b74a1172db712b92ed3d50cd2af603b2 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -131,22 +131,12 @@ impl From for Step { FetchDepth::Full => step.add_with(("fetch-depth", 0)), FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)), }) - .map(|step| match value.token { - Some(token) => step.add_with(("token", token)), - None => step, - }) - .map(|step| match value.path { - Some(path) => step.add_with(("path", path)), - None => step, - }) - .map(|step| match value.repository { - Some(repository) => step.add_with(("repository", repository)), - None => step, - }) - .map(|step| match value.ref_ { - Some(ref_) => step.add_with(("ref", ref_)), - None => step, + .when_some(value.path, |step, path| step.add_with(("path", path))) + .when_some(value.repository, |step, repository| { + step.add_with(("repository", repository)) }) + .when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_))) + .when_some(value.token, |step, token| step.add_with(("token", token))) } } From 0c49aaae3743e349dc18452c90877dbdee59bee1 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 11 Mar 2026 12:15:44 +0100 Subject: [PATCH 22/32] extension_rollout: Fix workflow file path (#51273) Release Notes: - N/A --- .github/workflows/extension_workflow_rollout.yml | 4 ++-- .../xtask/src/tasks/workflows/extension_workflow_rollout.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index cbb813d91749bf3843b64372f12e50f6a3c3e785..f695b43ecac47a221bbc795d03e6ddd6259d7014 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -159,9 +159,9 @@ jobs: cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp workflow-files/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/*.yml extension/.github/workflows/ else - cp workflow-files/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/shared/*.yml extension/.github/workflows/ fi env: REMOVED_CI: ${{ needs.fetch_extension_repos.outputs.removed_ci }} diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 91154c91061fd2e8a51e60704eca0f9b0b94c900..4e247fe16ca7b97638488c218684889c39cfcfa8 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -231,9 +231,9 @@ fn rollout_workflows_to_extension( cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp workflow-files/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/*.yml extension/.github/workflows/ else - cp workflow-files/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/shared/*.yml extension/.github/workflows/ fi "#}) .add_env(("REMOVED_CI", removed_ci)) From a540b7c22761a2fe8e2db0854a912c67c15819d4 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Wed, 11 Mar 2026 13:09:08 +0100 Subject: [PATCH 23/32] livekit_client: Always use output device's reported channels num (#51276) Fixes a panic in livekit's process_reverse_stream for non-matching channel counts, e.g., upmixing 2->4, or downmixing 2->1. Release Notes: - Fixed a panic in livekit when joining a channel using legacy audio using a device with less or more than 2 channels. --- crates/livekit_client/src/livekit_client/playback.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index 4933b05fc51592535c1f729ae8038a62103511ba..f62de78b4f9fb702f03943b06270abb41aa68e34 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -258,7 +258,7 @@ impl AudioStack { apm: Arc>, mixer: Arc>, sample_rate: u32, - num_channels: u32, + _num_channels: u32, output_audio_device: Option, ) -> Result<()> { // Prevent App Nap from throttling audio playback on macOS. @@ -270,6 +270,7 @@ impl AudioStack { let mut device_change_listener = DeviceChangeListener::new(false)?; let (output_device, output_config) = crate::default_device(false, output_audio_device.as_ref())?; + info!("Output config: {output_config:?}"); let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>(); let mixer = mixer.clone(); let apm = apm.clone(); @@ -300,7 +301,12 @@ impl AudioStack { let sampled = resampler.remix_and_resample( mixed, sample_rate / 100, - num_channels, + // We need to assume output number of channels as otherwise we will + // crash in process_reverse_stream otherwise as livekit's audio resampler + // does not seem to support non-matching channel counts. + // NOTE: you can verify this by debug printing buf.len() after this stage. + // For 2->4 channel upmix, we should see buf.len=1920, buf we get only 960. + output_config.channels() as u32, sample_rate, output_config.channels() as u32, output_config.sample_rate(), From cbb7982989cbe57370f5529495044710f0b2ed2b Mon Sep 17 00:00:00 2001 From: Abinash Date: Wed, 11 Mar 2026 18:13:56 +0530 Subject: [PATCH 24/32] docs: Allow navigating search results with arrow keys (#50901) Closes #50604 https://github.com/user-attachments/assets/8a85b39e-e463-4fee-bc1f-2a03fe193ce3 https://github.com/user-attachments/assets/8290bb06-1eaf-4852-9568-97654e30211e ### Results: Now, you can scroll the search results with the arrow keys. ### Suggestion: While this is working, I like to propose some better UX. When you scroll with the arrow keys, the whole search results are scrolling along with the search bar. But, I think it would be better if we keep the search bar fixed on top and only the results scroll. Here is an example: https://github.com/user-attachments/assets/af9dce73-27c6-407b-8180-2f771a85303b If you think this will be better, please let me know, and I will update this PR. Thank you. Release Notes: - Fixed docs search results scrolling with arrow keys --------- Co-authored-by: Gaauwe Rombouts --- docs/theme/css/chrome.css | 19 ++++++++++++------- docs/theme/index.hbs | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/theme/css/chrome.css b/docs/theme/css/chrome.css index 3f4fa40bc41a9c034c50c94c10fd8d0222d6b720..8f5b40cc19ecfd6cbedd0e5f76b5121afa5e5273 100644 --- a/docs/theme/css/chrome.css +++ b/docs/theme/css/chrome.css @@ -368,7 +368,10 @@ mark.fade-out { .searchbar-outer { margin-inline-start: auto; margin-inline-end: auto; + width: 100%; max-width: var(--content-max-width); + box-sizing: border-box; + padding: 16px; } #searchbar { @@ -394,21 +397,21 @@ mark.fade-out { .searchresults-header { font-weight: bold; font-size: 1em; - padding-block-start: 18px; + padding-block-start: 0; padding-block-end: 0; - padding-inline-start: 5px; - padding-inline-end: 0; color: var(--searchresults-header-fg); } ul#searchresults { list-style: none; padding-inline-start: 0; + margin-block-end: 0; } ul#searchresults li { margin: 10px 0px; padding: 2px; border-radius: 2px; + scroll-margin-block-end: 10px; } ul#searchresults li.focus { background-color: var(--searchresults-li-bg); @@ -794,8 +797,7 @@ ul#searchresults span.teaser em { max-height: 600px; display: flex; flex-direction: column; - padding: 16px; - overflow-y: auto; + overflow-y: hidden; border-radius: 8px; background: var(--popover-bg); @@ -803,8 +805,11 @@ ul#searchresults span.teaser em { box-shadow: var(--popover-shadow); } -.searchbar-outer { - width: 100%; +.searchresults-outer { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0px 22px 22px 22px; } #searchbar { diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs index 1c833ee94d428a1578b35c7944c4d300a04a21db..24378bcca6909b2e3e894c6c32db5f32d77921de 100644 --- a/docs/theme/index.hbs +++ b/docs/theme/index.hbs @@ -424,6 +424,31 @@ + + {{/if}} From 8d4913168c4ea3ac6a4d6cc1b70d3e7d006d8639 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 11 Mar 2026 13:52:03 +0100 Subject: [PATCH 25/32] acp: Update to `0.10.2` (#51280) Updates to `0.10.2`, most notable change is implementing `session/close`. Release Notes: - N/A --- Cargo.lock | 35 ++++++++++++++++++----- Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 2 +- crates/acp_thread/src/connection.rs | 6 +++- crates/agent/src/agent.rs | 6 +++- crates/agent_servers/src/acp.rs | 39 +++++++++++++++++++++----- crates/agent_ui/src/connection_view.rs | 26 +++++++++++------ 7 files changed, 89 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f11d2023b319501778768fdea39fb8dbb242a9e9..6fbffbcaff377bdf49d02afae172a19e72a2d188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,9 +227,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2659b1089101b15db31137710159421cb44785ecdb5ba784be3b4a6f8cb8a475" +checksum = "9c56a59cf6315e99f874d2c1f96c69d2da5ffe0087d211297fc4a41f849770a2" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -244,16 +244,16 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.10.8" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1" +checksum = "e0497b9a95a404e35799904835c57c6f8c69b9d08ccfd3cb5b7d746425cd6789" dependencies = [ "anyhow", "derive_more", "schemars", "serde", "serde_json", - "strum 0.27.2", + "strum 0.28.0", ] [[package]] @@ -7151,7 +7151,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "strum_macros", + "strum_macros 0.27.2", ] [[package]] @@ -16544,7 +16544,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -16559,6 +16568,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index c184837bfd6a67490169b7a6908b17b4d61e121f..f650dace84b1b2e6491acf2806077f72000605f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -473,7 +473,7 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -agent-client-protocol = { version = "=0.9.4", features = ["unstable"] } +agent-client-protocol = { version = "=0.10.2", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } any_vec = "0.14" diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 58252eaddca553eb1da4c960a829a88afb9eb497..95030443f642b019b27758f53fd413c5146857b1 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -4027,7 +4027,7 @@ mod tests { } fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task> { - if self.auth_methods().iter().any(|m| m.id == method) { + if self.auth_methods().iter().any(|m| m.id() == &method) { Task::ready(Ok(())) } else { Task::ready(Err(anyhow!("Invalid Auth Method"))) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 1236058226eee840e1a36009df85291a774548dc..4f6aaf86bad68f919c2c5de30214b21ff851c3dd 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -60,7 +60,11 @@ pub trait AgentConnection { } /// Close an existing session. Allows the agent to free the session from memory. - fn close_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task> { + fn close_session( + self: Rc, + _session_id: &acp::SessionId, + _cx: &mut App, + ) -> Task> { Task::ready(Err(anyhow::Error::msg("Closing sessions is not supported"))) } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index a62e219b2d075e10e074b55859fc6c366c25523d..95346d665732b40599b096d480178264601ce6d6 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1418,7 +1418,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection { true } - fn close_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task> { + fn close_session( + self: Rc, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task> { self.0.update(cx, |agent, _cx| { let project_id = agent.sessions.get(session_id).map(|s| s.project_id); agent.sessions.remove(session_id); diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index b9e4eba497ef1e01016a17e34d634fea20cab499..a661289f6221818c6f63c799b0593907bb665eb9 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -279,7 +279,7 @@ impl AcpConnection { acp::InitializeRequest::new(acp::ProtocolVersion::V1) .client_capabilities( acp::ClientCapabilities::new() - .fs(acp::FileSystemCapability::new() + .fs(acp::FileSystemCapabilities::new() .read_text_file(true) .write_text_file(true)) .terminal(true) @@ -331,11 +331,11 @@ impl AcpConnection { "env": command.env.clone().unwrap_or_default(), }); let meta = acp::Meta::from_iter([("terminal-auth".to_string(), value)]); - vec![ - acp::AuthMethod::new("spawn-gemini-cli", "Login") + vec![acp::AuthMethod::Agent( + acp::AuthMethodAgent::new("spawn-gemini-cli", "Login") .description("Login with your Google or Vertex AI account") .meta(meta), - ] + )] } else { response.auth_methods }; @@ -744,6 +744,31 @@ impl AgentConnection for AcpConnection { }) } + fn supports_close_session(&self) -> bool { + self.agent_capabilities.session_capabilities.close.is_some() + } + + fn close_session( + self: Rc, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task> { + if !self.agent_capabilities.session_capabilities.close.is_none() { + return Task::ready(Err(anyhow!(LoadError::Other( + "Closing sessions is not supported by this agent.".into() + )))); + } + + let conn = self.connection.clone(); + let session_id = session_id.clone(); + cx.foreground_executor().spawn(async move { + conn.close_session(acp::CloseSessionRequest::new(session_id.clone())) + .await?; + self.sessions.borrow_mut().remove(&session_id); + Ok(()) + }) + } + fn auth_methods(&self) -> &[acp::AuthMethod] { &self.auth_methods } @@ -1373,10 +1398,10 @@ impl acp::Client for ClientDelegate { Ok(acp::CreateTerminalResponse::new(terminal_id)) } - async fn kill_terminal_command( + async fn kill_terminal( &self, - args: acp::KillTerminalCommandRequest, - ) -> Result { + args: acp::KillTerminalRequest, + ) -> Result { self.session_thread(&args.session_id)? .update(&mut self.cx.clone(), |thread, cx| { thread.kill_terminal(args.terminal_id, cx) diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 3f1f1fb164693e0bb9e0b6d8883b97ab5539ba4f..2fd86f9c9d91abb7d5b08bd7a779b93592f2011c 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -463,7 +463,7 @@ impl ConnectedServerState { let tasks = self .threads .keys() - .map(|id| self.connection.close_session(id, cx)); + .map(|id| self.connection.clone().close_session(id, cx)); let task = futures::future::join_all(tasks); cx.background_spawn(async move { task.await; @@ -1431,7 +1431,7 @@ impl ConnectionView { .connection() .auth_methods() .iter() - .any(|method| method.id.0.as_ref() == "claude-login") + .any(|method| method.id().0.as_ref() == "claude-login") { available_commands.push(acp::AvailableCommand::new("login", "Authenticate")); available_commands.push(acp::AvailableCommand::new("logout", "Authenticate")); @@ -1495,10 +1495,15 @@ impl ConnectionView { let agent_telemetry_id = connection.telemetry_id(); // Check for the experimental "terminal-auth" _meta field - let auth_method = connection.auth_methods().iter().find(|m| m.id == method); + let auth_method = connection.auth_methods().iter().find(|m| m.id() == &method); if let Some(terminal_auth) = auth_method - .and_then(|a| a.meta.as_ref()) + .and_then(|a| match a { + acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(), + acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(), + acp::AuthMethod::Agent(agent) => agent.meta.as_ref(), + _ => None, + }) .and_then(|m| m.get("terminal-auth")) { // Extract terminal auth details from meta @@ -1882,7 +1887,7 @@ impl ConnectionView { .enumerate() .rev() .map(|(ix, method)| { - let (method_id, name) = (method.id.0.clone(), method.name.clone()); + let (method_id, name) = (method.id().0.clone(), method.name().to_string()); let agent_telemetry_id = connection.telemetry_id(); Button::new(method_id.clone(), name) @@ -1894,8 +1899,8 @@ impl ConnectionView { this.style(ButtonStyle::Outlined) } }) - .when_some(method.description.clone(), |this, description| { - this.tooltip(Tooltip::text(description)) + .when_some(method.description(), |this, description| { + this.tooltip(Tooltip::text(description.to_string())) }) .on_click({ cx.listener(move |this, _, window, cx| { @@ -4074,7 +4079,10 @@ pub(crate) mod tests { fn new() -> Self { Self { authenticated: Arc::new(Mutex::new(false)), - auth_method: acp::AuthMethod::new(Self::AUTH_METHOD_ID, "Test Login"), + auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new( + Self::AUTH_METHOD_ID, + "Test Login", + )), } } } @@ -4127,7 +4135,7 @@ pub(crate) mod tests { method_id: acp::AuthMethodId, _cx: &mut App, ) -> Task> { - if method_id == self.auth_method.id { + if &method_id == self.auth_method.id() { *self.authenticated.lock() = true; Task::ready(Ok(())) } else { From db9d9752c738158e3ded77aaf280ca3901d1ed52 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 11 Mar 2026 14:56:53 +0100 Subject: [PATCH 26/32] agent: Support providers streaming edits out of order (#51286) Release Notes: - N/A --- .../src/tools/streaming_edit_file_tool.rs | 127 +++++++++++++++++- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index c326ed3c10170d1c45517103ba02e178bec32c36..574fe078063b0b8e66ceb6cf0503ad139c23cdc4 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -118,7 +118,7 @@ pub struct Edit { pub new_text: String, } -#[derive(Default, Debug, Deserialize)] +#[derive(Clone, Default, Debug, Deserialize)] struct StreamingEditFileToolPartialInput { #[serde(default)] display_description: Option, @@ -132,7 +132,7 @@ struct StreamingEditFileToolPartialInput { edits: Option>, } -#[derive(Default, Debug, Deserialize)] +#[derive(Clone, Default, Debug, Deserialize)] pub struct PartialEdit { #[serde(default)] pub old_text: Option, @@ -314,12 +314,19 @@ impl AgentTool for StreamingEditFileTool { ) -> Task> { cx.spawn(async move |cx: &mut AsyncApp| { let mut state: Option = None; + let mut last_partial: Option = None; loop { futures::select! { partial = input.recv_partial().fuse() => { let Some(partial_value) = partial else { break }; if let Ok(parsed) = serde_json::from_value::(partial_value) { + let path_complete = parsed.path.is_some() + && parsed.path.as_ref() == last_partial.as_ref().and_then(|p| p.path.as_ref()); + + last_partial = Some(parsed.clone()); + if state.is_none() + && path_complete && let StreamingEditFileToolPartialInput { path: Some(path), display_description: Some(display_description), @@ -1907,6 +1914,13 @@ mod tests { let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); // Setup + single edit that stays in-progress (no second edit to prove completion) + sender.send_partial(json!({ + "display_description": "Single edit", + "path": "root/file.txt", + "mode": "edit", + })); + cx.run_until_parked(); + sender.send_partial(json!({ "display_description": "Single edit", "path": "root/file.txt", @@ -3475,6 +3489,12 @@ mod tests { let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); // Transition to BufferResolved + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + })); + cx.run_until_parked(); + sender.send_partial(json!({ "display_description": "Overwrite file", "path": "root/file.txt", @@ -3550,8 +3570,9 @@ mod tests { // Verify buffer still has old content (no content partial yet) let buffer = project.update(cx, |project, cx| { let path = project.find_project_path("root/file.txt", cx).unwrap(); - project.get_open_buffer(&path, cx).unwrap() + project.open_buffer(path, cx) }); + let buffer = buffer.await.unwrap(); assert_eq!( buffer.read_with(cx, |b, _| b.text()), "old line 1\nold line 2\nold line 3\n" @@ -3735,6 +3756,106 @@ mod tests { ); } + #[gpui::test] + async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode( + cx: &mut TestAppContext, + ) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content", + "path": "root" + })); + cx.run_until_parked(); + + // Send final. + sender.send_final(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content", + "path": "root/file.txt" + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + + #[gpui::test] + async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode( + cx: &mut TestAppContext, + ) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], + "path": "root" + })); + cx.run_until_parked(); + + // Send final. + sender.send_final(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], + "path": "root/file.txt" + })); + cx.run_until_parked(); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + async fn setup_test_with_fs( cx: &mut TestAppContext, fs: Arc, From 3c82ddf261cd31d6150cd4aebf4ccbfc6518ea2e Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:35:59 -0300 Subject: [PATCH 27/32] git_ui: Fix "resolve with agent" merge conflict notification (#51290) Follow up to https://github.com/zed-industries/zed/pull/49807 This PR fixes the merge conflict notification by making it appear only once per a given set of conflicted paths, as opposed to showing every time the `ConflictsUpdated` or `StatusesChanged` even would fire. Release Notes: - N/A --- crates/git_ui/src/conflict_view.rs | 15 +++++++++++---- crates/workspace/src/notifications.rs | 14 ++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 6c2c0b6f58696147da069b0aebdf55d396f7a388..7bb880abe6d1209aaf6b15d78979cc388bf37a36 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -15,7 +15,7 @@ use project::{ git_store::{GitStoreEvent, RepositoryEvent}, }; use settings::Settings; -use std::{ops::Range, sync::Arc}; +use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*}; use util::{ResultExt as _, debug_panic, maybe}; use workspace::{ @@ -534,7 +534,9 @@ pub(crate) fn register_conflict_notification( ) { let git_store = workspace.project().read(cx).git_store().clone(); - cx.subscribe(&git_store, |workspace, _git_store, event, cx| { + let last_shown_paths: Rc>> = Rc::new(RefCell::new(HashSet::default())); + + cx.subscribe(&git_store, move |workspace, _git_store, event, cx| { let conflicts_changed = matches!( event, GitStoreEvent::ConflictsUpdated @@ -546,10 +548,15 @@ pub(crate) fn register_conflict_notification( let paths = collect_conflicted_file_paths(workspace, cx); let notification_id = merge_conflict_notification_id(); + let current_paths_set: HashSet = paths.iter().cloned().collect(); if paths.is_empty() { + last_shown_paths.borrow_mut().clear(); workspace.dismiss_notification(¬ification_id, cx); - } else { + } else if *last_shown_paths.borrow() != current_paths_set { + // Only show the notification if the set of conflicted paths has changed. + // This prevents re-showing after the user dismisses it while working on the same conflicts. + *last_shown_paths.borrow_mut() = current_paths_set; let file_count = paths.len(); workspace.show_notification(notification_id, cx, |cx| { cx.new(|cx| { @@ -560,7 +567,7 @@ pub(crate) fn register_conflict_notification( }; MessageNotification::new(message, cx) - .primary_message("Resolve Conflicts with Agent") + .primary_message("Resolve with Agent") .primary_icon(IconName::ZedAssistant) .primary_icon_color(Color::Muted) .primary_on_click({ diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 84f479b77e4f0274e0775353d3a7cd5579768f1c..9f4b5538ed67bde3f32969467828296485b7810f 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -657,15 +657,17 @@ impl RenderOnce for NotificationFrame { IconButton::new(close_id, close_icon) .tooltip(move |_window, cx| { if suppress { - Tooltip::for_action( - "Suppress.\nClose with click.", - &SuppressNotification, + Tooltip::with_meta( + "Suppress", + Some(&SuppressNotification), + "Click to Close", cx, ) } else if show_suppress_button { - Tooltip::for_action( - "Close.\nSuppress with shift-click.", - &menu::Cancel, + Tooltip::with_meta( + "Close", + Some(&menu::Cancel), + "Shift-click to Suppress", cx, ) } else { From bee94e73923267d83c11d0cbad66293388e3c380 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 11 Mar 2026 10:41:31 -0400 Subject: [PATCH 28/32] Bump Zed to v0.229 (#51292) Release Notes: - N/A --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6fbffbcaff377bdf49d02afae172a19e72a2d188..6570398f5b22f2248a9cd59f84d2cf70080c3591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21756,7 +21756,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.228.0" +version = "0.229.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 2f61121d9c0aeb80a77d36bc4836b33c63936584..b38e5a774d7efe6e46642ed226515d7dff7275d3 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.228.0" +version = "0.229.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From a8def21f53c18720d9c846434db9caae486890da Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 11 Mar 2026 14:52:57 +0000 Subject: [PATCH 29/32] agent: Add thread git stats to sidebar (#51287) --- crates/action_log/src/action_log.rs | 45 +++++++++++++++++++ crates/agent_ui/src/connection_view.rs | 4 +- .../src/connection_view/thread_view.rs | 37 --------------- crates/agent_ui/src/sidebar.rs | 19 ++++++++ crates/ui/src/components/ai/thread_item.rs | 45 +++++++++---------- 5 files changed, 86 insertions(+), 64 deletions(-) diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 28245944e39deca7fb2b3f86902f114420d31d20..3faf767c7020763eadc7db6c93af42f650a07434 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -1028,6 +1028,11 @@ impl ActionLog { .collect() } + /// Returns the total number of lines added and removed across all unreviewed buffers. + pub fn diff_stats(&self, cx: &App) -> DiffStats { + DiffStats::all_files(&self.changed_buffers(cx), cx) + } + /// Iterate over buffers changed since last read or edited by the model pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator> { self.tracked_buffers @@ -1044,6 +1049,46 @@ impl ActionLog { } } +#[derive(Default, Debug, Clone, Copy)] +pub struct DiffStats { + pub lines_added: u32, + pub lines_removed: u32, +} + +impl DiffStats { + pub fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { + let mut stats = DiffStats::default(); + let diff_snapshot = diff.snapshot(cx); + let buffer_snapshot = buffer.snapshot(); + let base_text = diff_snapshot.base_text(); + + for hunk in diff_snapshot.hunks(&buffer_snapshot) { + let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); + stats.lines_added += added_rows; + + let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; + let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; + let removed_rows = base_end.saturating_sub(base_start); + stats.lines_removed += removed_rows; + } + + stats + } + + pub fn all_files( + changed_buffers: &BTreeMap, Entity>, + cx: &App, + ) -> Self { + let mut total = DiffStats::default(); + for (buffer, diff) in changed_buffers { + let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); + total.lines_added += stats.lines_added; + total.lines_removed += stats.lines_removed; + } + total + } +} + #[derive(Clone)] pub struct ActionLogTelemetry { pub agent_telemetry_id: SharedString, diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 2fd86f9c9d91abb7d5b08bd7a779b93592f2011c..b896741cee26e14ed372480f80d6cf8302db180b 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -5,7 +5,7 @@ use acp_thread::{ UserMessageId, }; use acp_thread::{AgentConnection, Plan}; -use action_log::{ActionLog, ActionLogTelemetry}; +use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; use agent_client_protocol::{self as acp, PromptCapabilities}; use agent_servers::AgentServer; @@ -46,7 +46,7 @@ use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; use terminal_view::terminal_panel::TerminalPanel; -use text::{Anchor, ToPoint as _}; +use text::Anchor; use theme::AgentFontSize; use ui::{ Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 771d80f08306838e756a2ea3dd8aa4b378cfd402..d4d23f5a0a0722afc5c588a355a6a9de1b59d194 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -156,43 +156,6 @@ impl ThreadFeedbackState { } } -#[derive(Default, Clone, Copy)] -struct DiffStats { - lines_added: u32, - lines_removed: u32, -} - -impl DiffStats { - fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { - let mut stats = DiffStats::default(); - let diff_snapshot = diff.snapshot(cx); - let buffer_snapshot = buffer.snapshot(); - let base_text = diff_snapshot.base_text(); - - for hunk in diff_snapshot.hunks(&buffer_snapshot) { - let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); - stats.lines_added += added_rows; - - let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; - let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; - let removed_rows = base_end.saturating_sub(base_start); - stats.lines_removed += removed_rows; - } - - stats - } - - fn all_files(changed_buffers: &BTreeMap, Entity>, cx: &App) -> Self { - let mut total = DiffStats::default(); - for (buffer, diff) in changed_buffers { - let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); - total.lines_added += stats.lines_added; - total.lines_removed += stats.lines_removed; - } - total - } -} - pub enum AcpThreadViewEvent { FirstSendRequested { content: Vec }, } diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 2679807388eb6261f9bc32be10c10ed500078b22..ae3a4f0ccb9df6073ae24a9c482b6c56de0ea968 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,5 +1,6 @@ use crate::{AgentPanel, AgentPanelEvent, NewThread}; use acp_thread::ThreadStatus; +use action_log::DiffStats; use agent::ThreadStore; use agent_client_protocol as acp; use agent_settings::AgentSettings; @@ -73,6 +74,7 @@ struct ActiveThreadInfo { icon: IconName, icon_from_external_svg: Option, is_background: bool, + diff_stats: DiffStats, } impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { @@ -98,6 +100,7 @@ struct ThreadEntry { is_live: bool, is_background: bool, highlight_positions: Vec, + diff_stats: DiffStats, } #[derive(Clone)] @@ -402,6 +405,8 @@ impl Sidebar { } }; + let diff_stats = thread.action_log().read(cx).diff_stats(cx); + ActiveThreadInfo { session_id, title, @@ -409,6 +414,7 @@ impl Sidebar { icon, icon_from_external_svg, is_background, + diff_stats, } }) .collect() @@ -472,6 +478,7 @@ impl Sidebar { is_live: false, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }); } } @@ -497,6 +504,7 @@ impl Sidebar { thread.icon_from_external_svg = info.icon_from_external_svg.clone(); thread.is_live = true; thread.is_background = info.is_background; + thread.diff_stats = info.diff_stats; } } @@ -1171,6 +1179,12 @@ impl Sidebar { .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) .notified(has_notification) + .when(thread.diff_stats.lines_added > 0, |this| { + this.added(thread.diff_stats.lines_added as usize) + }) + .when(thread.diff_stats.lines_removed > 0, |this| { + this.removed(thread.diff_stats.lines_removed as usize) + }) .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .focused(is_selected) .on_click(cx.listener(move |this, _, window, cx| { @@ -1987,6 +2001,7 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Active thread with Running status ListEntry::Thread(ThreadEntry { @@ -2005,6 +2020,7 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Active thread with Error status ListEntry::Thread(ThreadEntry { @@ -2023,6 +2039,7 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Thread with WaitingForConfirmation status, not active ListEntry::Thread(ThreadEntry { @@ -2041,6 +2058,7 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Background thread that completed (should show notification) ListEntry::Thread(ThreadEntry { @@ -2059,6 +2077,7 @@ mod tests { is_live: true, is_background: true, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // View More entry ListEntry::ViewMore { diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 3c08bd946710f76ccf49f933b82091a3bcb06e08..edc685159f5c9edc5fa872e9d453d0b81fa9cb16 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -227,6 +227,12 @@ impl RenderOnce for ThreadItem { .gradient_stop(0.8) .group_name("thread-item"); + let has_diff_stats = self.added.is_some() || self.removed.is_some(); + let added_count = self.added.unwrap_or(0); + let removed_count = self.removed.unwrap_or(0); + let diff_stat_id = self.id.clone(); + let has_worktree = self.worktree.is_some(); + v_flex() .id(self.id.clone()) .group("thread-item") @@ -235,7 +241,7 @@ impl RenderOnce for ThreadItem { .cursor_pointer() .w_full() .map(|this| { - if self.worktree.is_some() { + if has_worktree || has_diff_stats { this.p_2() } else { this.px_2().py_1() @@ -300,35 +306,24 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .child(worktree_label) - // TODO: Uncomment the elements below when we're ready to expose this data - // .child(dot_separator()) - // .child( - // Label::new(self.timestamp) - // .size(LabelSize::Small) - // .color(Color::Muted), - // ) - // .child( - // Label::new("•") - // .size(LabelSize::Small) - // .color(Color::Muted) - // .alpha(0.5), - // ) - // .when(has_no_changes, |this| { - // this.child( - // Label::new("No Changes") - // .size(LabelSize::Small) - // .color(Color::Muted), - // ) - // }) - .when(self.added.is_some() || self.removed.is_some(), |this| { + .when(has_diff_stats, |this| { this.child(DiffStat::new( - self.id, - self.added.unwrap_or(0), - self.removed.unwrap_or(0), + diff_stat_id.clone(), + added_count, + removed_count, )) }), ) }) + .when(!has_worktree && has_diff_stats, |this| { + this.child( + h_flex() + .min_w_0() + .gap_1p5() + .child(icon_container()) // Icon Spacing + .child(DiffStat::new(diff_stat_id, added_count, removed_count)), + ) + }) .when_some(self.on_click, |this, on_click| this.on_click(on_click)) } } From 2b425aa102142932a88275341913e9e3d99bbbec Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 11 Mar 2026 17:05:22 +0200 Subject: [PATCH 30/32] Limit editors' refresh runnables (#51299) Before, both rust-analyzer's LSPext tasks and tree-sitter tasks were queried on the entire multi buffer range. The PR moves all runnable-related logic into a submodule, and reworks the logic to consider only the visible buffers. Singleton buffers have their tasks resolved for the entire range still (same as LSPext tasks work), multi buffers have their buffers' data cached and reused. Release Notes: - Improved multi buffer's runnables resolution performance --- crates/editor/src/editor.rs | 526 ++--------------- crates/editor/src/editor_tests.rs | 17 +- crates/editor/src/element.rs | 6 +- crates/editor/src/runnables.rs | 915 ++++++++++++++++++++++++++++++ crates/editor/src/tasks.rs | 110 ---- crates/tasks_ui/src/tasks_ui.rs | 4 +- 6 files changed, 979 insertions(+), 599 deletions(-) create mode 100644 crates/editor/src/runnables.rs delete mode 100644 crates/editor/src/tasks.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a08ac3bbc466d159ce81a7aa3bebf82599914a0b..ca3dd81ab072d0e20389318515049793a8c827ef 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -35,13 +35,13 @@ mod lsp_ext; mod mouse_context_menu; pub mod movement; mod persistence; +mod runnables; mod rust_analyzer_ext; pub mod scroll; mod selections_collection; pub mod semantic_tokens; mod split; pub mod split_editor_view; -pub mod tasks; #[cfg(test)] mod code_completion_tests; @@ -133,8 +133,8 @@ use language::{ BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape, DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, LocalFile, OffsetRangeExt, - OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId, - TreeSitterOptions, WordsQuery, + OutlineItem, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, + WordsQuery, language_settings::{ self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings, language_settings, @@ -158,7 +158,7 @@ use project::{ BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId, InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project, - ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind, + ProjectItem, ProjectPath, ProjectTransaction, debugger::{ breakpoint_store::{ Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState, @@ -200,7 +200,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables}; +use task::TaskVariables; use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _}; use theme::{ AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, @@ -231,6 +231,7 @@ use crate::{ InlineValueCache, inlay_hints::{LspInlayHintData, inlay_hint_settings}, }, + runnables::{ResolvedTasks, RunnableData, RunnableTasks}, scroll::{ScrollOffset, ScrollPixelOffset}, selections_collection::resolve_selections_wrapping_blocks, semantic_tokens::SemanticTokenState, @@ -857,37 +858,6 @@ impl BufferSerialization { } } -#[derive(Clone, Debug)] -struct RunnableTasks { - templates: Vec<(TaskSourceKind, TaskTemplate)>, - offset: multi_buffer::Anchor, - // We need the column at which the task context evaluation should take place (when we're spawning it via gutter). - column: u32, - // Values of all named captures, including those starting with '_' - extra_variables: HashMap, - // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal. - context_range: Range, -} - -impl RunnableTasks { - fn resolve<'a>( - &'a self, - cx: &'a task::TaskContext, - ) -> impl Iterator + 'a { - self.templates.iter().filter_map(|(kind, template)| { - template - .resolve_task(&kind.to_id_base(), cx) - .map(|task| (kind.clone(), task)) - }) - } -} - -#[derive(Clone)] -pub struct ResolvedTasks { - templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, - position: Anchor, -} - /// Addons allow storing per-editor state in other crates (e.g. Vim) pub trait Addon: 'static { fn extend_key_context(&self, _: &mut KeyContext, _: &App) {} @@ -1295,8 +1265,7 @@ pub struct Editor { last_bounds: Option>, last_position_map: Option>, expect_bounds_change: Option>, - tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, - tasks_update_task: Option>, + runnables: RunnableData, breakpoint_store: Option>, gutter_breakpoint_indicator: (Option, Option>), pub(crate) gutter_diff_review_indicator: (Option, Option>), @@ -2173,16 +2142,9 @@ impl Editor { editor.registered_buffers.clear(); editor.register_visible_buffers(cx); editor.invalidate_semantic_tokens(None); + editor.refresh_runnables(window, cx); editor.update_lsp_data(None, window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); - if editor.tasks_update_task.is_none() { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); - } - } - project::Event::LanguageServerAdded(..) => { - if editor.tasks_update_task.is_none() { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); - } } project::Event::SnippetEdit(id, snippet_edits) => { // todo(lw): Non singletons @@ -2210,6 +2172,7 @@ impl Editor { let buffer_id = *buffer_id; if editor.buffer().read(cx).buffer(buffer_id).is_some() { editor.register_buffer(buffer_id, cx); + editor.refresh_runnables(window, cx); editor.update_lsp_data(Some(buffer_id), window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); refresh_linked_ranges(editor, window, cx); @@ -2288,7 +2251,7 @@ impl Editor { &task_inventory, window, |editor, _, window, cx| { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); + editor.refresh_runnables(window, cx); }, )); }; @@ -2529,7 +2492,6 @@ impl Editor { }), blame: None, blame_subscription: None, - tasks: BTreeMap::default(), breakpoint_store, gutter_breakpoint_indicator: (None, None), @@ -2565,7 +2527,7 @@ impl Editor { ] }) .unwrap_or_default(), - tasks_update_task: None, + runnables: RunnableData::new(), pull_diagnostics_task: Task::ready(()), colors: None, refresh_colors_task: Task::ready(()), @@ -2632,7 +2594,6 @@ impl Editor { cx.notify(); })); } - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); editor._subscriptions.extend(project_subscriptions); editor._subscriptions.push(cx.subscribe_in( @@ -2668,6 +2629,7 @@ impl Editor { ); if !editor.buffer().read(cx).is_singleton() { editor.update_lsp_data(None, window, cx); + editor.refresh_runnables(window, cx); } }) .ok(); @@ -5791,18 +5753,11 @@ impl Editor { let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let multi_buffer = self.buffer().read(cx); let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let multi_buffer_visible_start = self - .scroll_manager - .native_anchor(&display_snapshot, cx) - .anchor - .to_point(&multi_buffer_snapshot); - let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( - multi_buffer_visible_start - + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), - Bias::Left, - ); multi_buffer_snapshot - .range_to_buffer_ranges(multi_buffer_visible_start..=multi_buffer_visible_end) + .range_to_buffer_ranges( + self.multi_buffer_visible_range(&display_snapshot, cx) + .to_inclusive(), + ) .into_iter() .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) .filter_map(|(buffer, excerpt_visible_range, excerpt_id)| { @@ -6737,8 +6692,8 @@ impl Editor { }; let buffer_id = buffer.read(cx).remote_id(); let tasks = self - .tasks - .get(&(buffer_id, buffer_row)) + .runnables + .runnables((buffer_id, buffer_row)) .map(|t| Arc::new(t.to_owned())); if !self.focus_handle.is_focused(window) { @@ -7789,24 +7744,13 @@ impl Editor { self.debounced_selection_highlight_complete = false; } if on_buffer_edit || query_changed { - let multi_buffer_visible_start = self - .scroll_manager - .native_anchor(&display_snapshot, cx) - .anchor - .to_point(&multi_buffer_snapshot); - let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( - multi_buffer_visible_start - + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), - Bias::Left, - ); - let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end; self.quick_selection_highlight_task = Some(( query_range.clone(), self.update_selection_occurrence_highlights( snapshot.buffer.clone(), query_text.clone(), query_range.clone(), - multi_buffer_visible_range, + self.multi_buffer_visible_range(&display_snapshot, cx), false, window, cx, @@ -7841,6 +7785,27 @@ impl Editor { } } + pub fn multi_buffer_visible_range( + &self, + display_snapshot: &DisplaySnapshot, + cx: &App, + ) -> Range { + let visible_start = self + .scroll_manager + .native_anchor(display_snapshot, cx) + .anchor + .to_point(display_snapshot.buffer_snapshot()) + .to_display_point(display_snapshot); + + let mut target_end = visible_start; + *target_end.row_mut() += self.visible_line_count().unwrap_or(0.).ceil() as u32; + + visible_start.to_point(display_snapshot) + ..display_snapshot + .clip_point(target_end, Bias::Right) + .to_point(display_snapshot) + } + pub fn refresh_edit_prediction( &mut self, debounce: bool, @@ -8809,19 +8774,6 @@ impl Editor { Some(self.edit_prediction_provider.as_ref()?.provider.clone()) } - fn clear_tasks(&mut self) { - self.tasks.clear() - } - - fn insert_tasks(&mut self, key: (BufferId, BufferRow), value: RunnableTasks) { - if self.tasks.insert(key, value).is_some() { - // This case should hopefully be rare, but just in case... - log::error!( - "multiple different run targets found on a single line, only the last target will be rendered" - ) - } - } - /// Get all display points of breakpoints that will be rendered within editor /// /// This function is used to handle overlaps between breakpoints and Code action/runner symbol. @@ -9199,156 +9151,6 @@ impl Editor { }) } - pub fn spawn_nearest_task( - &mut self, - action: &SpawnNearestTask, - window: &mut Window, - cx: &mut Context, - ) { - let Some((workspace, _)) = self.workspace.clone() else { - return; - }; - let Some(project) = self.project.clone() else { - return; - }; - - // Try to find a closest, enclosing node using tree-sitter that has a task - let Some((buffer, buffer_row, tasks)) = self - .find_enclosing_node_task(cx) - // Or find the task that's closest in row-distance. - .or_else(|| self.find_closest_task(cx)) - else { - return; - }; - - let reveal_strategy = action.reveal; - let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx); - cx.spawn_in(window, async move |_, cx| { - let context = task_context.await?; - let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?; - - let resolved = &mut resolved_task.resolved; - resolved.reveal = reveal_strategy; - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - }) - .ok() - }) - .detach(); - } - - fn find_closest_task( - &mut self, - cx: &mut Context, - ) -> Option<(Entity, u32, Arc)> { - let cursor_row = self - .selections - .newest_adjusted(&self.display_snapshot(cx)) - .head() - .row; - - let ((buffer_id, row), tasks) = self - .tasks - .iter() - .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?; - - let buffer = self.buffer.read(cx).buffer(*buffer_id)?; - let tasks = Arc::new(tasks.to_owned()); - Some((buffer, *row, tasks)) - } - - fn find_enclosing_node_task( - &mut self, - cx: &mut Context, - ) -> Option<(Entity, u32, Arc)> { - let snapshot = self.buffer.read(cx).snapshot(cx); - let offset = self - .selections - .newest::(&self.display_snapshot(cx)) - .head(); - let mut excerpt = snapshot.excerpt_containing(offset..offset)?; - let offset = excerpt.map_offset_to_buffer(offset); - let buffer_id = excerpt.buffer().remote_id(); - - let layer = excerpt.buffer().syntax_layer_at(offset)?; - let mut cursor = layer.node().walk(); - - while cursor.goto_first_child_for_byte(offset.0).is_some() { - if cursor.node().end_byte() == offset.0 { - cursor.goto_next_sibling(); - } - } - - // Ascend to the smallest ancestor that contains the range and has a task. - loop { - let node = cursor.node(); - let node_range = node.byte_range(); - let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row; - - // Check if this node contains our offset - if node_range.start <= offset.0 && node_range.end >= offset.0 { - // If it contains offset, check for task - if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) { - let buffer = self.buffer.read(cx).buffer(buffer_id)?; - return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned()))); - } - } - - if !cursor.goto_parent() { - break; - } - } - None - } - - fn render_run_indicator( - &self, - _style: &EditorStyle, - is_active: bool, - row: DisplayRow, - breakpoint: Option<(Anchor, Breakpoint, Option)>, - cx: &mut Context, - ) -> IconButton { - let color = Color::Muted; - let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor); - - IconButton::new( - ("run_indicator", row.0 as usize), - ui::IconName::PlayOutlined, - ) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(color) - .toggle_state(is_active) - .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { - let quick_launch = match e { - ClickEvent::Keyboard(_) => true, - ClickEvent::Mouse(e) => e.down.button == MouseButton::Left, - }; - - window.focus(&editor.focus_handle(cx), cx); - editor.toggle_code_actions( - &ToggleCodeActions { - deployed_from: Some(CodeActionSource::RunMenu(row)), - quick_launch, - }, - window, - cx, - ); - })) - .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { - editor.set_breakpoint_context_menu(row, position, event.position(), window, cx); - })) - } - pub fn context_menu_visible(&self) -> bool { !self.edit_prediction_preview_is_active() && self @@ -17153,241 +16955,6 @@ impl Editor { }); } - fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { - if !self.mode().is_full() - || !EditorSettings::get_global(cx).gutter.runnables - || !self.enable_runnables - { - self.clear_tasks(); - return Task::ready(()); - } - let project = self.project().map(Entity::downgrade); - let task_sources = self.lsp_task_sources(cx); - let multi_buffer = self.buffer.downgrade(); - let lsp_data_enabled = self.lsp_data_enabled(); - cx.spawn_in(window, async move |editor, cx| { - cx.background_executor().timer(UPDATE_DEBOUNCE).await; - let Some(project) = project.and_then(|p| p.upgrade()) else { - return; - }; - let Ok(display_snapshot) = editor.update(cx, |this, cx| { - this.display_map.update(cx, |map, cx| map.snapshot(cx)) - }) else { - return; - }; - - let hide_runnables = project.update(cx, |project, _| project.is_via_collab()); - if hide_runnables { - return; - } - let new_rows = cx - .background_spawn({ - let snapshot = display_snapshot.clone(); - async move { - snapshot - .buffer_snapshot() - .runnable_ranges(Anchor::min()..Anchor::max()) - .collect() - } - }) - .await; - let lsp_tasks = if lsp_data_enabled { - let Ok(lsp_tasks) = - cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx)) - else { - return; - }; - lsp_tasks.await - } else { - Vec::new() - }; - - let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { - lsp_tasks - .into_iter() - .flat_map(|(kind, tasks)| { - tasks.into_iter().filter_map(move |(location, task)| { - Some((kind.clone(), location?, task)) - }) - }) - .fold(HashMap::default(), |mut acc, (kind, location, task)| { - let buffer = location.target.buffer; - let buffer_snapshot = buffer.read(cx).snapshot(); - let offset = display_snapshot.buffer_snapshot().excerpts().find_map( - |(excerpt_id, snapshot, _)| { - if snapshot.remote_id() == buffer_snapshot.remote_id() { - display_snapshot - .buffer_snapshot() - .anchor_in_excerpt(excerpt_id, location.target.range.start) - } else { - None - } - }, - ); - if let Some(offset) = offset { - let task_buffer_range = - location.target.range.to_point(&buffer_snapshot); - let context_buffer_range = - task_buffer_range.to_offset(&buffer_snapshot); - let context_range = BufferOffset(context_buffer_range.start) - ..BufferOffset(context_buffer_range.end); - - acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row)) - .or_insert_with(|| RunnableTasks { - templates: Vec::new(), - offset, - column: task_buffer_range.start.column, - extra_variables: HashMap::default(), - context_range, - }) - .templates - .push((kind, task.original_task().clone())); - } - - acc - }) - }) else { - return; - }; - - let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| { - buffer.language_settings(cx).tasks.prefer_lsp - }) else { - return; - }; - - let rows = Self::runnable_rows( - project, - display_snapshot, - prefer_lsp && !lsp_tasks_by_rows.is_empty(), - new_rows, - cx.clone(), - ) - .await; - editor - .update(cx, |editor, _| { - editor.clear_tasks(); - for (key, mut value) in rows { - if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&key) { - value.templates.extend(lsp_tasks.templates); - } - - editor.insert_tasks(key, value); - } - for (key, value) in lsp_tasks_by_rows { - editor.insert_tasks(key, value); - } - }) - .ok(); - }) - } - - fn runnable_rows( - project: Entity, - snapshot: DisplaySnapshot, - prefer_lsp: bool, - runnable_ranges: Vec<(Range, language::RunnableRange)>, - cx: AsyncWindowContext, - ) -> Task> { - cx.spawn(async move |cx| { - let mut runnable_rows = Vec::with_capacity(runnable_ranges.len()); - for (run_range, mut runnable) in runnable_ranges { - let Some(tasks) = cx - .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) - .ok() - else { - continue; - }; - let mut tasks = tasks.await; - - if prefer_lsp { - tasks.retain(|(task_kind, _)| { - !matches!(task_kind, TaskSourceKind::Language { .. }) - }); - } - if tasks.is_empty() { - continue; - } - - let point = run_range.start.to_point(&snapshot.buffer_snapshot()); - let Some(row) = snapshot - .buffer_snapshot() - .buffer_line_for_row(MultiBufferRow(point.row)) - .map(|(_, range)| range.start.row) - else { - continue; - }; - - let context_range = - BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end); - runnable_rows.push(( - (runnable.buffer_id, row), - RunnableTasks { - templates: tasks, - offset: snapshot.buffer_snapshot().anchor_before(run_range.start), - context_range, - column: point.column, - extra_variables: runnable.extra_captures, - }, - )); - } - runnable_rows - }) - } - - fn templates_with_tags( - project: &Entity, - runnable: &mut Runnable, - cx: &mut App, - ) -> Task> { - let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| { - let (worktree_id, file) = project - .buffer_for_id(runnable.buffer, cx) - .and_then(|buffer| buffer.read(cx).file()) - .map(|file| (file.worktree_id(cx), file.clone())) - .unzip(); - - ( - project.task_store().read(cx).task_inventory().cloned(), - worktree_id, - file, - ) - }); - - let tags = mem::take(&mut runnable.tags); - let language = runnable.language.clone(); - cx.spawn(async move |cx| { - let mut templates_with_tags = Vec::new(); - if let Some(inventory) = inventory { - for RunnableTag(tag) in tags { - let new_tasks = inventory.update(cx, |inventory, cx| { - inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx) - }); - templates_with_tags.extend(new_tasks.await.into_iter().filter( - move |(_, template)| { - template.tags.iter().any(|source_tag| source_tag == &tag) - }, - )); - } - } - templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned()); - - if let Some((leading_tag_source, _)) = templates_with_tags.first() { - // Strongest source wins; if we have worktree tag binding, prefer that to - // global and language bindings; - // if we have a global binding, prefer that to language binding. - let first_mismatch = templates_with_tags - .iter() - .position(|(tag_source, _)| tag_source != leading_tag_source); - if let Some(index) = first_mismatch { - templates_with_tags.truncate(index); - } - } - - templates_with_tags - }) - } - pub fn move_to_enclosing_bracket( &mut self, _: &MoveToEnclosingBracket, @@ -24184,7 +23751,6 @@ impl Editor { predecessor, excerpts, } => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); let buffer_id = buffer.read(cx).remote_id(); if self.buffer.read(cx).diff_for(buffer_id).is_none() && let Some(project) = &self.project @@ -24202,6 +23768,7 @@ impl Editor { .invalidate_buffer(&buffer.read(cx).remote_id()); self.update_lsp_data(Some(buffer_id), window, cx); self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + self.refresh_runnables(window, cx); self.colorize_brackets(false, cx); self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx); cx.emit(EditorEvent::ExcerptsAdded { @@ -24220,8 +23787,7 @@ impl Editor { self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); for buffer_id in removed_buffer_ids { self.registered_buffers.remove(buffer_id); - self.tasks - .retain(|(task_buffer_id, _), _| task_buffer_id != buffer_id); + self.clear_runnables(Some(*buffer_id)); self.semantic_token_state.invalidate_buffer(buffer_id); self.display_map.update(cx, |display_map, cx| { display_map.invalidate_semantic_highlights(*buffer_id); @@ -24263,10 +23829,12 @@ impl Editor { } self.colorize_brackets(false, cx); self.update_lsp_data(None, window, cx); + self.refresh_runnables(window, cx); cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() }) } multi_buffer::Event::Reparsed(buffer_id) => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + self.clear_runnables(Some(*buffer_id)); + self.refresh_runnables(window, cx); self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx); self.colorize_brackets(true, cx); jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); @@ -24274,7 +23842,7 @@ impl Editor { cx.emit(EditorEvent::Reparsed(*buffer_id)); } multi_buffer::Event::DiffHunksToggled => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + self.refresh_runnables(window, cx); } multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => { if !is_fresh_language { @@ -24410,7 +23978,7 @@ impl Editor { .unwrap_or(DiagnosticSeverity::Hint); self.set_max_diagnostics_severity(new_severity, cx); } - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + self.refresh_runnables(window, cx); self.update_edit_prediction_settings(cx); self.refresh_edit_prediction(true, false, window, cx); self.refresh_inline_values(cx); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index d3da58733dd0a24622a6dcde87f638069e206cf4..fe71cb76f0f16dc7a928ccff725585c0e857c62e 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -5,6 +5,7 @@ use crate::{ edit_prediction_tests::FakeEditPredictionDelegate, element::StickyHeader, linked_editing_ranges::LinkedEditingRanges, + runnables::RunnableTasks, scroll::scroll_amount::ScrollAmount, test::{ assert_text_with_selections, build_editor, editor_content_with_blocks, @@ -24403,20 +24404,24 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { editor.update_in(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); - editor.tasks.insert( - (buffer.read(cx).remote_id(), 3), + editor.runnables.insert( + buffer.read(cx).remote_id(), + 3, + buffer.read(cx).version(), RunnableTasks { - templates: vec![], + templates: Vec::new(), offset: snapshot.anchor_before(MultiBufferOffset(43)), column: 0, extra_variables: HashMap::default(), context_range: BufferOffset(43)..BufferOffset(85), }, ); - editor.tasks.insert( - (buffer.read(cx).remote_id(), 8), + editor.runnables.insert( + buffer.read(cx).remote_id(), + 8, + buffer.read(cx).version(), RunnableTasks { - templates: vec![], + templates: Vec::new(), offset: snapshot.anchor_before(MultiBufferOffset(86)), column: 0, extra_variables: HashMap::default(), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index b7207fce71bc71c5bdd5962ca3328030935238ca..3b1356525960654ea88c6cfa84115f1e67ac2e5b 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3275,9 +3275,9 @@ impl EditorElement { snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right); editor - .tasks - .iter() - .filter_map(|(_, tasks)| { + .runnables + .all_runnables() + .filter_map(|tasks| { let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot()); if multibuffer_point < offset_range_start || multibuffer_point > offset_range_end diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs new file mode 100644 index 0000000000000000000000000000000000000000..9fa6b89ec130e74f388c5e82b9b346197bb13abb --- /dev/null +++ b/crates/editor/src/runnables.rs @@ -0,0 +1,915 @@ +use std::{collections::BTreeMap, mem, ops::Range, sync::Arc}; + +use clock::Global; +use collections::HashMap; +use gpui::{ + App, AppContext as _, AsyncWindowContext, ClickEvent, Context, Entity, Focusable as _, + MouseButton, Task, Window, +}; +use language::{Buffer, BufferRow, Runnable}; +use lsp::LanguageServerName; +use multi_buffer::{ + Anchor, BufferOffset, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, ToPoint as _, +}; +use project::{ + Location, Project, TaskSourceKind, + debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, + project_settings::ProjectSettings, +}; +use settings::Settings as _; +use smallvec::SmallVec; +use task::{ResolvedTask, RunnableTag, TaskContext, TaskTemplate, TaskVariables, VariableName}; +use text::{BufferId, OffsetRangeExt as _, ToOffset as _, ToPoint as _}; +use ui::{Clickable as _, Color, IconButton, IconSize, Toggleable as _}; + +use crate::{ + CodeActionSource, Editor, EditorSettings, EditorStyle, RangeToAnchorExt, SpawnNearestTask, + ToggleCodeActions, UPDATE_DEBOUNCE, display_map::DisplayRow, +}; + +#[derive(Debug)] +pub(super) struct RunnableData { + runnables: HashMap)>, + runnables_update_task: Task<()>, +} + +impl RunnableData { + pub fn new() -> Self { + Self { + runnables: HashMap::default(), + runnables_update_task: Task::ready(()), + } + } + + pub fn runnables( + &self, + (buffer_id, buffer_row): (BufferId, BufferRow), + ) -> Option<&RunnableTasks> { + self.runnables.get(&buffer_id)?.1.get(&buffer_row) + } + + pub fn all_runnables(&self) -> impl Iterator { + self.runnables + .values() + .flat_map(|(_, tasks)| tasks.values()) + } + + pub fn has_cached(&self, buffer_id: BufferId, version: &Global) -> bool { + self.runnables + .get(&buffer_id) + .is_some_and(|(cached_version, _)| !version.changed_since(cached_version)) + } + + #[cfg(test)] + pub fn insert( + &mut self, + buffer_id: BufferId, + buffer_row: BufferRow, + version: Global, + tasks: RunnableTasks, + ) { + self.runnables + .entry(buffer_id) + .or_insert_with(|| (version, BTreeMap::default())) + .1 + .insert(buffer_row, tasks); + } +} + +#[derive(Clone, Debug)] +pub struct RunnableTasks { + pub templates: Vec<(TaskSourceKind, TaskTemplate)>, + pub offset: multi_buffer::Anchor, + // We need the column at which the task context evaluation should take place (when we're spawning it via gutter). + pub column: u32, + // Values of all named captures, including those starting with '_' + pub extra_variables: HashMap, + // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal. + pub context_range: Range, +} + +impl RunnableTasks { + pub fn resolve<'a>( + &'a self, + cx: &'a task::TaskContext, + ) -> impl Iterator + 'a { + self.templates.iter().filter_map(|(kind, template)| { + template + .resolve_task(&kind.to_id_base(), cx) + .map(|task| (kind.clone(), task)) + }) + } +} + +#[derive(Clone)] +pub struct ResolvedTasks { + pub templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, + pub position: Anchor, +} + +impl Editor { + pub fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) { + if !self.mode().is_full() + || !EditorSettings::get_global(cx).gutter.runnables + || !self.enable_runnables + { + self.clear_runnables(None); + return; + } + if let Some(buffer) = self.buffer().read(cx).as_singleton() { + if self + .runnables + .has_cached(buffer.read(cx).remote_id(), &buffer.read(cx).version()) + { + return; + } + } + + let project = self.project().map(Entity::downgrade); + let lsp_task_sources = self.lsp_task_sources(true, true, cx); + let multi_buffer = self.buffer.downgrade(); + self.runnables.runnables_update_task = cx.spawn_in(window, async move |editor, cx| { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + let Some(project) = project.and_then(|p| p.upgrade()) else { + return; + }; + + let hide_runnables = project.update(cx, |project, _| project.is_via_collab()); + if hide_runnables { + return; + } + let lsp_tasks = if lsp_task_sources.is_empty() { + Vec::new() + } else { + let Ok(lsp_tasks) = cx + .update(|_, cx| crate::lsp_tasks(project.clone(), &lsp_task_sources, None, cx)) + else { + return; + }; + lsp_tasks.await + }; + let new_rows = { + let Some((multi_buffer_snapshot, multi_buffer_query_range)) = editor + .update(cx, |editor, cx| { + let multi_buffer = editor.buffer().read(cx); + if multi_buffer.is_singleton() { + Some((multi_buffer.snapshot(cx), Anchor::min()..Anchor::max())) + } else { + let display_snapshot = + editor.display_map.update(cx, |map, cx| map.snapshot(cx)); + let multi_buffer_query_range = + editor.multi_buffer_visible_range(&display_snapshot, cx); + let multi_buffer_snapshot = display_snapshot.buffer(); + Some(( + multi_buffer_snapshot.clone(), + multi_buffer_query_range.to_anchors(&multi_buffer_snapshot), + )) + } + }) + .ok() + .flatten() + else { + return; + }; + cx.background_spawn({ + async move { + multi_buffer_snapshot + .runnable_ranges(multi_buffer_query_range) + .collect() + } + }) + .await + }; + + let Ok(multi_buffer_snapshot) = + editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) + else { + return; + }; + let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { + lsp_tasks + .into_iter() + .flat_map(|(kind, tasks)| { + tasks.into_iter().filter_map(move |(location, task)| { + Some((kind.clone(), location?, task)) + }) + }) + .fold(HashMap::default(), |mut acc, (kind, location, task)| { + let buffer = location.target.buffer; + let buffer_snapshot = buffer.read(cx).snapshot(); + let offset = multi_buffer_snapshot.excerpts().find_map( + |(excerpt_id, snapshot, _)| { + if snapshot.remote_id() == buffer_snapshot.remote_id() { + multi_buffer_snapshot + .anchor_in_excerpt(excerpt_id, location.target.range.start) + } else { + None + } + }, + ); + if let Some(offset) = offset { + let task_buffer_range = + location.target.range.to_point(&buffer_snapshot); + let context_buffer_range = + task_buffer_range.to_offset(&buffer_snapshot); + let context_range = BufferOffset(context_buffer_range.start) + ..BufferOffset(context_buffer_range.end); + + acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row)) + .or_insert_with(|| RunnableTasks { + templates: Vec::new(), + offset, + column: task_buffer_range.start.column, + extra_variables: HashMap::default(), + context_range, + }) + .templates + .push((kind, task.original_task().clone())); + } + + acc + }) + }) else { + return; + }; + + let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| { + buffer.language_settings(cx).tasks.prefer_lsp + }) else { + return; + }; + + let rows = Self::runnable_rows( + project, + multi_buffer_snapshot, + prefer_lsp && !lsp_tasks_by_rows.is_empty(), + new_rows, + cx.clone(), + ) + .await; + editor + .update(cx, |editor, cx| { + for ((buffer_id, row), mut new_tasks) in rows { + let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else { + continue; + }; + + if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&(buffer_id, row)) { + new_tasks.templates.extend(lsp_tasks.templates); + } + editor.insert_runnables( + buffer_id, + buffer.read(cx).version(), + row, + new_tasks, + ); + } + for ((buffer_id, row), new_tasks) in lsp_tasks_by_rows { + let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else { + continue; + }; + editor.insert_runnables( + buffer_id, + buffer.read(cx).version(), + row, + new_tasks, + ); + } + }) + .ok(); + }); + } + + pub fn spawn_nearest_task( + &mut self, + action: &SpawnNearestTask, + window: &mut Window, + cx: &mut Context, + ) { + let Some((workspace, _)) = self.workspace.clone() else { + return; + }; + let Some(project) = self.project.clone() else { + return; + }; + + // Try to find a closest, enclosing node using tree-sitter that has a task + let Some((buffer, buffer_row, tasks)) = self + .find_enclosing_node_task(cx) + // Or find the task that's closest in row-distance. + .or_else(|| self.find_closest_task(cx)) + else { + return; + }; + + let reveal_strategy = action.reveal; + let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx); + cx.spawn_in(window, async move |_, cx| { + let context = task_context.await?; + let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?; + + let resolved = &mut resolved_task.resolved; + resolved.reveal = reveal_strategy; + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.schedule_resolved_task( + task_source_kind, + resolved_task, + false, + window, + cx, + ); + }) + .ok() + }) + .detach(); + } + + pub fn clear_runnables(&mut self, for_buffer: Option) { + if let Some(buffer_id) = for_buffer { + self.runnables.runnables.remove(&buffer_id); + } else { + self.runnables.runnables.clear(); + } + self.runnables.runnables_update_task = Task::ready(()); + } + + pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task> { + let Some(project) = self.project.clone() else { + return Task::ready(None); + }; + let (selection, buffer, editor_snapshot) = { + let selection = self.selections.newest_adjusted(&self.display_snapshot(cx)); + let Some((buffer, _)) = self + .buffer() + .read(cx) + .point_to_buffer_offset(selection.start, cx) + else { + return Task::ready(None); + }; + let snapshot = self.snapshot(window, cx); + (selection, buffer, snapshot) + }; + let selection_range = selection.range(); + let start = editor_snapshot + .display_snapshot + .buffer_snapshot() + .anchor_after(selection_range.start) + .text_anchor; + let end = editor_snapshot + .display_snapshot + .buffer_snapshot() + .anchor_after(selection_range.end) + .text_anchor; + let location = Location { + buffer, + range: start..end, + }; + let captured_variables = { + let mut variables = TaskVariables::default(); + let buffer = location.buffer.read(cx); + let buffer_id = buffer.remote_id(); + let snapshot = buffer.snapshot(); + let starting_point = location.range.start.to_point(&snapshot); + let starting_offset = starting_point.to_offset(&snapshot); + for (_, tasks) in self + .runnables + .runnables + .get(&buffer_id) + .into_iter() + .flat_map(|(_, tasks)| tasks.range(0..starting_point.row + 1)) + { + if !tasks + .context_range + .contains(&crate::BufferOffset(starting_offset)) + { + continue; + } + for (capture_name, value) in tasks.extra_variables.iter() { + variables.insert( + VariableName::Custom(capture_name.to_owned().into()), + value.clone(), + ); + } + } + variables + }; + + project.update(cx, |project, cx| { + project.task_store().update(cx, |task_store, cx| { + task_store.task_context_for_location(captured_variables, location, cx) + }) + }) + } + + pub fn lsp_task_sources( + &self, + visible_only: bool, + skip_cached: bool, + cx: &mut Context, + ) -> HashMap> { + if !self.lsp_data_enabled() { + return HashMap::default(); + } + let buffers = if visible_only { + self.visible_excerpts(true, cx) + .into_values() + .map(|(buffer, _, _)| buffer) + .collect() + } else { + self.buffer().read(cx).all_buffers() + }; + + let lsp_settings = &ProjectSettings::get_global(cx).lsp; + + buffers + .into_iter() + .filter_map(|buffer| { + let lsp_tasks_source = buffer + .read(cx) + .language()? + .context_provider()? + .lsp_task_source()?; + if lsp_settings + .get(&lsp_tasks_source) + .is_none_or(|s| s.enable_lsp_tasks) + { + let buffer_id = buffer.read(cx).remote_id(); + if skip_cached + && self + .runnables + .has_cached(buffer_id, &buffer.read(cx).version()) + { + None + } else { + Some((lsp_tasks_source, buffer_id)) + } + } else { + None + } + }) + .fold( + HashMap::default(), + |mut acc, (lsp_task_source, buffer_id)| { + acc.entry(lsp_task_source) + .or_insert_with(Vec::new) + .push(buffer_id); + acc + }, + ) + } + + pub fn find_enclosing_node_task( + &mut self, + cx: &mut Context, + ) -> Option<(Entity, u32, Arc)> { + let snapshot = self.buffer.read(cx).snapshot(cx); + let offset = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); + let mut excerpt = snapshot.excerpt_containing(offset..offset)?; + let offset = excerpt.map_offset_to_buffer(offset); + let buffer_id = excerpt.buffer().remote_id(); + + let layer = excerpt.buffer().syntax_layer_at(offset)?; + let mut cursor = layer.node().walk(); + + while cursor.goto_first_child_for_byte(offset.0).is_some() { + if cursor.node().end_byte() == offset.0 { + cursor.goto_next_sibling(); + } + } + + // Ascend to the smallest ancestor that contains the range and has a task. + loop { + let node = cursor.node(); + let node_range = node.byte_range(); + let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row; + + // Check if this node contains our offset + if node_range.start <= offset.0 && node_range.end >= offset.0 { + // If it contains offset, check for task + if let Some(tasks) = self + .runnables + .runnables + .get(&buffer_id) + .and_then(|(_, tasks)| tasks.get(&symbol_start_row)) + { + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned()))); + } + } + + if !cursor.goto_parent() { + break; + } + } + None + } + + pub fn render_run_indicator( + &self, + _style: &EditorStyle, + is_active: bool, + row: DisplayRow, + breakpoint: Option<(Anchor, Breakpoint, Option)>, + cx: &mut Context, + ) -> IconButton { + let color = Color::Muted; + let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor); + + IconButton::new( + ("run_indicator", row.0 as usize), + ui::IconName::PlayOutlined, + ) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(color) + .toggle_state(is_active) + .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { + let quick_launch = match e { + ClickEvent::Keyboard(_) => true, + ClickEvent::Mouse(e) => e.down.button == MouseButton::Left, + }; + + window.focus(&editor.focus_handle(cx), cx); + editor.toggle_code_actions( + &ToggleCodeActions { + deployed_from: Some(CodeActionSource::RunMenu(row)), + quick_launch, + }, + window, + cx, + ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu(row, position, event.position(), window, cx); + })) + } + + fn insert_runnables( + &mut self, + buffer: BufferId, + version: Global, + row: BufferRow, + new_tasks: RunnableTasks, + ) { + let (old_version, tasks) = self.runnables.runnables.entry(buffer).or_default(); + if !old_version.changed_since(&version) { + *old_version = version; + tasks.insert(row, new_tasks); + } + } + + fn runnable_rows( + project: Entity, + snapshot: MultiBufferSnapshot, + prefer_lsp: bool, + runnable_ranges: Vec<(Range, language::RunnableRange)>, + cx: AsyncWindowContext, + ) -> Task> { + cx.spawn(async move |cx| { + let mut runnable_rows = Vec::with_capacity(runnable_ranges.len()); + for (run_range, mut runnable) in runnable_ranges { + let Some(tasks) = cx + .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) + .ok() + else { + continue; + }; + let mut tasks = tasks.await; + + if prefer_lsp { + tasks.retain(|(task_kind, _)| { + !matches!(task_kind, TaskSourceKind::Language { .. }) + }); + } + if tasks.is_empty() { + continue; + } + + let point = run_range.start.to_point(&snapshot); + let Some(row) = snapshot + .buffer_line_for_row(MultiBufferRow(point.row)) + .map(|(_, range)| range.start.row) + else { + continue; + }; + + let context_range = + BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end); + runnable_rows.push(( + (runnable.buffer_id, row), + RunnableTasks { + templates: tasks, + offset: snapshot.anchor_before(run_range.start), + context_range, + column: point.column, + extra_variables: runnable.extra_captures, + }, + )); + } + runnable_rows + }) + } + + fn templates_with_tags( + project: &Entity, + runnable: &mut Runnable, + cx: &mut App, + ) -> Task> { + let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| { + let (worktree_id, file) = project + .buffer_for_id(runnable.buffer, cx) + .and_then(|buffer| buffer.read(cx).file()) + .map(|file| (file.worktree_id(cx), file.clone())) + .unzip(); + + ( + project.task_store().read(cx).task_inventory().cloned(), + worktree_id, + file, + ) + }); + + let tags = mem::take(&mut runnable.tags); + let language = runnable.language.clone(); + cx.spawn(async move |cx| { + let mut templates_with_tags = Vec::new(); + if let Some(inventory) = inventory { + for RunnableTag(tag) in tags { + let new_tasks = inventory.update(cx, |inventory, cx| { + inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx) + }); + templates_with_tags.extend(new_tasks.await.into_iter().filter( + move |(_, template)| { + template.tags.iter().any(|source_tag| source_tag == &tag) + }, + )); + } + } + templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned()); + + if let Some((leading_tag_source, _)) = templates_with_tags.first() { + // Strongest source wins; if we have worktree tag binding, prefer that to + // global and language bindings; + // if we have a global binding, prefer that to language binding. + let first_mismatch = templates_with_tags + .iter() + .position(|(tag_source, _)| tag_source != leading_tag_source); + if let Some(index) = first_mismatch { + templates_with_tags.truncate(index); + } + } + + templates_with_tags + }) + } + + fn find_closest_task( + &mut self, + cx: &mut Context, + ) -> Option<(Entity, u32, Arc)> { + let cursor_row = self + .selections + .newest_adjusted(&self.display_snapshot(cx)) + .head() + .row; + + let ((buffer_id, row), tasks) = self + .runnables + .runnables + .iter() + .flat_map(|(buffer_id, (_, tasks))| { + tasks.iter().map(|(row, tasks)| ((*buffer_id, *row), tasks)) + }) + .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?; + + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + let tasks = Arc::new(tasks.to_owned()); + Some((buffer, row, tasks)) + } +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use gpui::{AppContext as _, Task, TestAppContext}; + use indoc::indoc; + use language::ContextProvider; + use languages::rust_lang; + use multi_buffer::{MultiBuffer, PathKey}; + use project::{FakeFs, Project}; + use serde_json::json; + use task::{TaskTemplate, TaskTemplates}; + use text::Point; + use util::path; + + use crate::{ + Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount, + }; + + struct TestRustContextProvider; + + impl ContextProvider for TestRustContextProvider { + fn associated_tasks( + &self, + _: Option>, + _: &gpui::App, + ) -> Task> { + Task::ready(Some(TaskTemplates(vec![ + TaskTemplate { + label: "Run main".into(), + command: "cargo".into(), + args: vec!["run".into()], + tags: vec!["rust-main".into()], + ..TaskTemplate::default() + }, + TaskTemplate { + label: "Run test".into(), + command: "cargo".into(), + args: vec!["test".into()], + tags: vec!["rust-test".into()], + ..TaskTemplate::default() + }, + ]))) + } + } + + fn rust_lang_with_task_context() -> Arc { + Arc::new( + Arc::try_unwrap(rust_lang()) + .unwrap() + .with_context_provider(Some(Arc::new(TestRustContextProvider))), + ) + } + + fn collect_runnable_labels( + editor: &Editor, + ) -> Vec<(text::BufferId, language::BufferRow, Vec)> { + let mut result = editor + .runnables + .runnables + .iter() + .flat_map(|(buffer_id, (_, tasks))| { + tasks.iter().map(move |(row, runnable_tasks)| { + let mut labels: Vec = runnable_tasks + .templates + .iter() + .map(|(_, template)| template.label.clone()) + .collect(); + labels.sort(); + (*buffer_id, *row, labels) + }) + }) + .collect::>(); + result.sort_by_key(|(id, row, _)| (*id, *row)); + result + } + + #[gpui::test] + async fn test_multi_buffer_runnables_on_scroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let padding_lines = 50; + let mut first_rs = String::from("fn main() {\n println!(\"hello\");\n}\n"); + for _ in 0..padding_lines { + first_rs.push_str("//\n"); + } + let test_one_row = 3 + padding_lines as u32 + 1; + first_rs.push_str("#[test]\nfn test_one() {\n assert!(true);\n}\n"); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "first.rs": first_rs, + "second.rs": indoc! {" + #[test] + fn test_two() { + assert!(true); + } + + #[test] + fn test_three() { + assert!(true); + } + "}, + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang_with_task_context()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/first.rs"), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/second.rs"), cx) + }) + .await + .unwrap(); + + let buffer_1_id = buffer_1.read_with(cx, |buffer, _| buffer.remote_id()); + let buffer_2_id = buffer_2.read_with(cx, |buffer, _| buffer.remote_id()); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite); + let end = buffer_1.read(cx).max_point(); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..end], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(8, 1)], + 0, + cx, + ); + multi_buffer + }); + + let editor = cx.add_window(|window, cx| { + Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx) + }); + cx.executor().advance_clock(Duration::from_millis(500)); + cx.executor().run_until_parked(); + + // Clear stale data from startup events, then refresh. + // first.rs is long enough that second.rs is below the ~47-line viewport. + editor + .update(cx, |editor, window, cx| { + editor.clear_runnables(None); + editor.refresh_runnables(window, cx); + }) + .unwrap(); + cx.executor().advance_clock(UPDATE_DEBOUNCE); + cx.executor().run_until_parked(); + assert_eq!( + editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .unwrap(), + vec![(buffer_1_id, 0, vec!["Run main".to_string()])], + "Only fn main from first.rs should be visible before scrolling" + ); + + // Scroll down to bring second.rs excerpts into view. + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.executor().run_until_parked(); + + let after_scroll = editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .unwrap(); + assert_eq!( + after_scroll, + vec![ + (buffer_1_id, 0, vec!["Run main".to_string()]), + (buffer_1_id, test_one_row, vec!["Run test".to_string()]), + (buffer_2_id, 1, vec!["Run test".to_string()]), + (buffer_2_id, 6, vec!["Run test".to_string()]), + ], + "Tree-sitter should detect both #[test] fns in second.rs after scroll" + ); + + // Edit second.rs to invalidate its cache; first.rs data should persist. + buffer_2.update(cx, |buffer, cx| { + buffer.edit([(0..0, "// added comment\n")], None, cx); + }); + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Page(-1.0), window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.executor().run_until_parked(); + + assert_eq!( + editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .unwrap(), + vec![ + (buffer_1_id, 0, vec!["Run main".to_string()]), + (buffer_1_id, test_one_row, vec!["Run test".to_string()]), + ], + "first.rs runnables should survive an edit to second.rs" + ); + } +} diff --git a/crates/editor/src/tasks.rs b/crates/editor/src/tasks.rs deleted file mode 100644 index e39880ddc1f575a7b12f40c5496c75c1f473c6e9..0000000000000000000000000000000000000000 --- a/crates/editor/src/tasks.rs +++ /dev/null @@ -1,110 +0,0 @@ -use crate::Editor; - -use collections::HashMap; -use gpui::{App, Task, Window}; -use lsp::LanguageServerName; -use project::{Location, project_settings::ProjectSettings}; -use settings::Settings as _; -use task::{TaskContext, TaskVariables, VariableName}; -use text::{BufferId, ToOffset, ToPoint}; - -impl Editor { - pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task> { - let Some(project) = self.project.clone() else { - return Task::ready(None); - }; - let (selection, buffer, editor_snapshot) = { - let selection = self.selections.newest_adjusted(&self.display_snapshot(cx)); - let Some((buffer, _)) = self - .buffer() - .read(cx) - .point_to_buffer_offset(selection.start, cx) - else { - return Task::ready(None); - }; - let snapshot = self.snapshot(window, cx); - (selection, buffer, snapshot) - }; - let selection_range = selection.range(); - let start = editor_snapshot - .display_snapshot - .buffer_snapshot() - .anchor_after(selection_range.start) - .text_anchor; - let end = editor_snapshot - .display_snapshot - .buffer_snapshot() - .anchor_after(selection_range.end) - .text_anchor; - let location = Location { - buffer, - range: start..end, - }; - let captured_variables = { - let mut variables = TaskVariables::default(); - let buffer = location.buffer.read(cx); - let buffer_id = buffer.remote_id(); - let snapshot = buffer.snapshot(); - let starting_point = location.range.start.to_point(&snapshot); - let starting_offset = starting_point.to_offset(&snapshot); - for (_, tasks) in self - .tasks - .range((buffer_id, 0)..(buffer_id, starting_point.row + 1)) - { - if !tasks - .context_range - .contains(&crate::BufferOffset(starting_offset)) - { - continue; - } - for (capture_name, value) in tasks.extra_variables.iter() { - variables.insert( - VariableName::Custom(capture_name.to_owned().into()), - value.clone(), - ); - } - } - variables - }; - - project.update(cx, |project, cx| { - project.task_store().update(cx, |task_store, cx| { - task_store.task_context_for_location(captured_variables, location, cx) - }) - }) - } - - pub fn lsp_task_sources(&self, cx: &App) -> HashMap> { - let lsp_settings = &ProjectSettings::get_global(cx).lsp; - - self.buffer() - .read(cx) - .all_buffers() - .into_iter() - .filter_map(|buffer| { - let lsp_tasks_source = buffer - .read(cx) - .language()? - .context_provider()? - .lsp_task_source()?; - if lsp_settings - .get(&lsp_tasks_source) - .is_none_or(|s| s.enable_lsp_tasks) - { - let buffer_id = buffer.read(cx).remote_id(); - Some((lsp_tasks_source, buffer_id)) - } else { - None - } - }) - .fold( - HashMap::default(), - |mut acc, (lsp_task_source, buffer_id)| { - acc.entry(lsp_task_source) - .or_insert_with(Vec::new) - .push(buffer_id); - acc - }, - ) - } -} diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index 29e6a9de7fab9b5421fe38fee0fd24fd43b12ccc..fdacef3b193beb8a656916edb61fbff1a200385b 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/crates/tasks_ui/src/tasks_ui.rs @@ -316,7 +316,9 @@ pub fn task_contexts( let lsp_task_sources = active_editor .as_ref() - .map(|active_editor| active_editor.update(cx, |editor, cx| editor.lsp_task_sources(cx))) + .map(|active_editor| { + active_editor.update(cx, |editor, cx| editor.lsp_task_sources(false, false, cx)) + }) .unwrap_or_default(); let latest_selection = active_editor.as_ref().map(|active_editor| { From dfc3a7c6e87cdc0465e152f216f2d9c561116af1 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 11 Mar 2026 16:57:22 +0100 Subject: [PATCH 31/32] agent_ui: Move UI logic from `ThreadHistory` to separate component (#51301) This is just a re-factor. We'll make use of this once we make thread history non-global (storing one history per ACP connection). Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 15 +- crates/agent_ui/src/agent_ui.rs | 4 +- crates/agent_ui/src/connection_view.rs | 16 +- .../src/connection_view/thread_view.rs | 2 +- crates/agent_ui/src/entry_view_state.rs | 3 +- crates/agent_ui/src/inline_assistant.rs | 2 +- crates/agent_ui/src/message_editor.rs | 48 +- crates/agent_ui/src/thread_history.rs | 959 +----------------- crates/agent_ui/src/thread_history_view.rs | 878 ++++++++++++++++ 9 files changed, 944 insertions(+), 983 deletions(-) create mode 100644 crates/agent_ui/src/thread_history_view.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 630411c2400ee925f980b5d3a410cb3574e81cd6..1537c05096ec81f1b3f354cac236bfdda52c9f6f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -48,7 +48,7 @@ use crate::{ NewNativeAgentThreadFromSummary, }; use crate::{ - ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent, + ExpandMessageEditor, ThreadHistory, ThreadHistoryView, ThreadHistoryViewEvent, text_thread_history::{TextThreadHistory, TextThreadHistoryEvent}, }; use agent_settings::AgentSettings; @@ -863,6 +863,7 @@ pub struct AgentPanel { fs: Arc, language_registry: Arc, acp_history: Entity, + acp_history_view: Entity, text_thread_history: Entity, thread_store: Entity, text_thread_store: Entity, @@ -1072,14 +1073,15 @@ impl AgentPanel { cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let thread_store = ThreadStore::global(cx); - let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx)); + let acp_history = cx.new(|cx| ThreadHistory::new(None, cx)); + let acp_history_view = cx.new(|cx| ThreadHistoryView::new(acp_history.clone(), window, cx)); let text_thread_history = cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx)); cx.subscribe_in( - &acp_history, + &acp_history_view, window, |this, _, event, window, cx| match event { - ThreadHistoryEvent::Open(thread) => { + ThreadHistoryViewEvent::Open(thread) => { this.load_agent_thread( thread.session_id.clone(), thread.cwd.clone(), @@ -1213,6 +1215,7 @@ impl AgentPanel { pending_serialization: None, onboarding, acp_history, + acp_history_view, text_thread_history, thread_store, selected_agent: AgentType::default(), @@ -3046,7 +3049,7 @@ impl Focusable for AgentPanel { ActiveView::Uninitialized => self.focus_handle.clone(), ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx), ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => self.acp_history.focus_handle(cx), + HistoryKind::AgentThreads => self.acp_history_view.focus_handle(cx), HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), }, ActiveView::TextThread { @@ -4763,7 +4766,7 @@ impl Render for AgentPanel { .child(server_view.clone()) .child(self.render_drag_target(cx)), ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => parent.child(self.acp_history.clone()), + HistoryKind::AgentThreads => parent.child(self.acp_history_view.clone()), HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()), }, ActiveView::TextThread { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 292db8fc7c0398fdd8c8800b8acc2b3c6df22740..52ce6f0bd7a312966b6602fb43be4074d7f3e620 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -33,6 +33,7 @@ pub mod test_support; mod text_thread_editor; mod text_thread_history; mod thread_history; +mod thread_history_view; mod ui; use std::rc::Rc; @@ -74,7 +75,8 @@ pub(crate) use mode_selector::ModeSelector; pub(crate) use model_selector::ModelSelector; pub(crate) use model_selector_popover::ModelSelectorPopover; pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor}; -pub(crate) use thread_history::*; +pub(crate) use thread_history::ThreadHistory; +pub(crate) use thread_history_view::*; use zed_actions; actions!( diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index b896741cee26e14ed372480f80d6cf8302db180b..b562688a83b75b75a1b95c065b14d0484daef055 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -2901,7 +2901,7 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); // Create history without an initial session list - it will be set after connection - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3007,7 +3007,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3066,7 +3066,7 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3123,7 +3123,7 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3180,7 +3180,7 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3498,7 +3498,7 @@ pub(crate) mod tests { // Set up thread view in workspace 1 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx))); @@ -3718,7 +3718,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -4454,7 +4454,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index d4d23f5a0a0722afc5c588a355a6a9de1b59d194..44f9e78a2bb47af6cb171194fbd5a34de7383f1b 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -7409,7 +7409,7 @@ impl ThreadView { // TODO: Add keyboard navigation. let is_hovered = self.hovered_recent_history_item == Some(index); - crate::thread_history::HistoryEntryElement::new( + crate::thread_history_view::HistoryEntryElement::new( entry, self.server_view.clone(), ) diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index aef7f1f335eff7d092f924b9883ab0d64bbf65a8..17769335a1cc7e514bad15862d20d4048a089b7b 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -508,8 +508,7 @@ mod tests { }); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let view_state = cx.new(|_cx| { EntryViewState::new( diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 4e7eecfe07aac84269cb1d325cc5a95943578863..2aee2b4601e126b25a977cf92d314970049026da 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -2155,7 +2155,7 @@ pub mod test { }); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = cx.new(|cx| crate::ThreadHistory::new(None, window, cx)); + let history = cx.new(|cx| crate::ThreadHistory::new(None, cx)); // Add editor to workspace workspace.update(cx, |workspace, cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 6c2628f9d37efd0531d5663ac4b1d27d9ae5ae0f..c9067d4ec261261e66c7718b36ebcb96b2099fed 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1708,8 +1708,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -1822,8 +1821,7 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let workspace_handle = workspace.downgrade(); let message_editor = workspace.update_in(cx, |_, window, cx| { cx.new(|cx| { @@ -1978,8 +1976,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), @@ -2213,8 +2210,7 @@ mod tests { } let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -2709,8 +2705,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2810,8 +2805,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let session_id = acp::SessionId::new("thread-123"); let title = Some("Previous Conversation".into()); @@ -2886,8 +2880,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2943,8 +2936,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2998,8 +2990,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3054,8 +3045,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3119,8 +3109,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3279,8 +3268,7 @@ mod tests { }); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); // Create a new `MessageEditor`. The `EditorMode::full()` has to be used // to ensure we have a fixed viewport, so we can eventually actually @@ -3400,8 +3388,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3483,8 +3470,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3568,8 +3554,7 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3721,8 +3706,7 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index 01536b00e98d13a699457377a6ebf8e9e87a59b4..5e66d4468767e7002b8b5f6c79ffe8aaecf77127 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -1,118 +1,21 @@ -use crate::ConnectionView; -use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate}; use agent_client_protocol as acp; -use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; -use editor::{Editor, EditorEvent}; -use fuzzy::StringMatchCandidate; -use gpui::{ - App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, - UniformListScrollHandle, WeakEntity, Window, uniform_list, -}; -use std::{fmt::Display, ops::Range, rc::Rc}; -use text::Bias; -use time::{OffsetDateTime, UtcOffset}; -use ui::{ - ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, - WithScrollbar, prelude::*, -}; - -const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); - -fn thread_title(entry: &AgentSessionInfo) -> &SharedString { - entry - .title - .as_ref() - .filter(|title| !title.is_empty()) - .unwrap_or(DEFAULT_TITLE) -} +use gpui::{App, Task}; +use std::rc::Rc; +use ui::prelude::*; pub struct ThreadHistory { session_list: Option>, sessions: Vec, - scroll_handle: UniformListScrollHandle, - selected_index: usize, - hovered_index: Option, - search_editor: Entity, - search_query: SharedString, - visible_items: Vec, - local_timezone: UtcOffset, - confirming_delete_history: bool, - _visible_items_task: Task<()>, _refresh_task: Task<()>, _watch_task: Option>, - _subscriptions: Vec, -} - -enum ListItemType { - BucketSeparator(TimeBucket), - Entry { - entry: AgentSessionInfo, - format: EntryTimeFormat, - }, - SearchResult { - entry: AgentSessionInfo, - positions: Vec, - }, -} - -impl ListItemType { - fn history_entry(&self) -> Option<&AgentSessionInfo> { - match self { - ListItemType::Entry { entry, .. } => Some(entry), - ListItemType::SearchResult { entry, .. } => Some(entry), - _ => None, - } - } } -pub enum ThreadHistoryEvent { - Open(AgentSessionInfo), -} - -impl EventEmitter for ThreadHistory {} - impl ThreadHistory { - pub fn new( - session_list: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let search_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search threads...", window, cx); - editor - }); - - let search_editor_subscription = - cx.subscribe(&search_editor, |this, search_editor, event, cx| { - if let EditorEvent::BufferEdited = event { - let query = search_editor.read(cx).text(cx); - if this.search_query != query { - this.search_query = query.into(); - this.update_visible_items(false, cx); - } - } - }); - - let scroll_handle = UniformListScrollHandle::default(); - + pub fn new(session_list: Option>, cx: &mut Context) -> Self { let mut this = Self { session_list: None, sessions: Vec::new(), - scroll_handle, - selected_index: 0, - hovered_index: None, - visible_items: Default::default(), - search_editor, - local_timezone: UtcOffset::from_whole_seconds( - chrono::Local::now().offset().local_minus_utc(), - ) - .unwrap(), - search_query: SharedString::default(), - confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription], - _visible_items_task: Task::ready(()), _refresh_task: Task::ready(()), _watch_task: None, }; @@ -120,43 +23,6 @@ impl ThreadHistory { this } - fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let entries = self.sessions.clone(); - let new_list_items = if self.search_query.is_empty() { - self.add_list_separators(entries, cx) - } else { - self.filter_search_results(entries, cx) - }; - let selected_history_entry = if preserve_selected_item { - self.selected_history_entry().cloned() - } else { - None - }; - - self._visible_items_task = cx.spawn(async move |this, cx| { - let new_visible_items = new_list_items.await; - this.update(cx, |this, cx| { - let new_selected_index = if let Some(history_entry) = selected_history_entry { - new_visible_items - .iter() - .position(|visible_entry| { - visible_entry - .history_entry() - .is_some_and(|entry| entry.session_id == history_entry.session_id) - }) - .unwrap_or(0) - } else { - 0 - }; - - this.visible_items = new_visible_items; - this.set_selected_index(new_selected_index, Bias::Right, cx); - cx.notify(); - }) - .ok(); - }); - } - pub fn set_session_list( &mut self, session_list: Option>, @@ -170,9 +36,6 @@ impl ThreadHistory { self.session_list = session_list; self.sessions.clear(); - self.visible_items.clear(); - self.selected_index = 0; - self._visible_items_task = Task::ready(()); self._refresh_task = Task::ready(()); let Some(session_list) = self.session_list.as_ref() else { @@ -181,9 +44,8 @@ impl ThreadHistory { return; }; let Some(rx) = session_list.watch(cx) else { - // No watch support - do a one-time refresh self._watch_task = None; - self.refresh_sessions(false, false, cx); + self.refresh_sessions(false, cx); return; }; session_list.notify_refresh(); @@ -191,7 +53,6 @@ impl ThreadHistory { self._watch_task = Some(cx.spawn(async move |this, cx| { while let Ok(first_update) = rx.recv().await { let mut updates = vec![first_update]; - // Collect any additional updates that are already in the channel while let Ok(update) = rx.try_recv() { updates.push(update); } @@ -202,7 +63,7 @@ impl ThreadHistory { .any(|u| matches!(u, SessionListUpdate::Refresh)); if needs_refresh { - this.refresh_sessions(true, false, cx); + this.refresh_sessions(false, cx); } else { for update in updates { if let SessionListUpdate::SessionInfo { session_id, update } = update { @@ -217,7 +78,7 @@ impl ThreadHistory { } pub(crate) fn refresh_full_history(&mut self, cx: &mut Context) { - self.refresh_sessions(true, true, cx); + self.refresh_sessions(true, cx); } fn apply_info_update( @@ -258,23 +119,15 @@ impl ThreadHistory { session.meta = Some(meta); } - self.update_visible_items(true, cx); + cx.notify(); } - fn refresh_sessions( - &mut self, - preserve_selected_item: bool, - load_all_pages: bool, - cx: &mut Context, - ) { + fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context) { let Some(session_list) = self.session_list.clone() else { - self.update_visible_items(preserve_selected_item, cx); + cx.notify(); return; }; - // If a new refresh arrives while pagination is in progress, the previous - // `_refresh_task` is cancelled. This is intentional (latest refresh wins), - // but means sessions may be in a partial state until the new refresh completes. self._refresh_task = cx.spawn(async move |this, cx| { let mut cursor: Option = None; let mut is_first_page = true; @@ -305,7 +158,7 @@ impl ThreadHistory { } else { this.sessions.extend(page_sessions); } - this.update_visible_items(preserve_selected_item, cx); + cx.notify(); }) .ok(); @@ -378,693 +231,11 @@ impl ThreadHistory { } } - fn add_list_separators( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - cx.background_spawn(async move { - let mut items = Vec::with_capacity(entries.len() + 1); - let mut bucket = None; - let today = Local::now().naive_local().date(); - - for entry in entries.into_iter() { - let entry_bucket = entry - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::All); - - if Some(entry_bucket) != bucket { - bucket = Some(entry_bucket); - items.push(ListItemType::BucketSeparator(entry_bucket)); - } - - items.push(ListItemType::Entry { - entry, - format: entry_bucket.into(), - }); - } - items - }) - } - - fn filter_search_results( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - let query = self.search_query.clone(); - cx.background_spawn({ - let executor = cx.background_executor().clone(); - async move { - let mut candidates = Vec::with_capacity(entries.len()); - - for (idx, entry) in entries.iter().enumerate() { - candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); - } - - const MAX_MATCHES: usize = 100; - - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - MAX_MATCHES, - &Default::default(), - executor, - ) - .await; - - matches - .into_iter() - .map(|search_match| ListItemType::SearchResult { - entry: entries[search_match.candidate_id].clone(), - positions: search_match.positions, - }) - .collect() - } - }) - } - - fn search_produced_no_matches(&self) -> bool { - self.visible_items.is_empty() && !self.search_query.is_empty() - } - - fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { - self.get_history_entry(self.selected_index) - } - - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { - self.visible_items.get(visible_items_ix)?.history_entry() - } - - fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { - if self.visible_items.len() == 0 { - self.selected_index = 0; - return; - } - while matches!( - self.visible_items.get(index), - None | Some(ListItemType::BucketSeparator(..)) - ) { - index = match bias { - Bias::Left => { - if index == 0 { - self.visible_items.len() - 1 - } else { - index - 1 - } - } - Bias::Right => { - if index >= self.visible_items.len() - 1 { - 0 - } else { - index + 1 - } - } - }; - } - self.selected_index = index; - self.scroll_handle - .scroll_to_item(index, ScrollStrategy::Top); - cx.notify() - } - - pub fn select_previous( - &mut self, - _: &menu::SelectPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == 0 { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } else { - self.set_selected_index(self.selected_index - 1, Bias::Left, cx); - } - } - - pub fn select_next( - &mut self, - _: &menu::SelectNext, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == self.visible_items.len() - 1 { - self.set_selected_index(0, Bias::Right, cx); + pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task> { + if let Some(session_list) = self.session_list.as_ref() { + session_list.delete_sessions(cx) } else { - self.set_selected_index(self.selected_index + 1, Bias::Right, cx); - } - } - - fn select_first( - &mut self, - _: &menu::SelectFirst, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_selected_index(0, Bias::Right, cx); - } - - fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } - - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - self.confirm_entry(self.selected_index, cx); - } - - fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(ix) else { - return; - }; - cx.emit(ThreadHistoryEvent::Open(entry.clone())); - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - self.remove_thread(self.selected_index, cx) - } - - fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(visible_item_ix) else { - return; - }; - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { - return; - } - let task = session_list.delete_session(&entry.session_id, cx); - task.detach_and_log_err(cx); - } - - fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { - return; - } - session_list.delete_sessions(cx).detach_and_log_err(cx); - self.confirming_delete_history = false; - cx.notify(); - } - - fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = true; - cx.notify(); - } - - fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = false; - cx.notify(); - } - - fn render_list_items( - &mut self, - range: Range, - _window: &mut Window, - cx: &mut Context, - ) -> Vec { - self.visible_items - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) - .collect() - } - - fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { - match item { - ListItemType::Entry { entry, format } => self - .render_history_entry(entry, *format, ix, Vec::default(), cx) - .into_any(), - ListItemType::SearchResult { entry, positions } => self.render_history_entry( - entry, - EntryTimeFormat::DateAndTime, - ix, - positions.clone(), - cx, - ), - ListItemType::BucketSeparator(bucket) => div() - .px(DynamicSpacing::Base06.rems(cx)) - .pt_2() - .pb_1() - .child( - Label::new(bucket.to_string()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - } - } - - fn render_history_entry( - &self, - entry: &AgentSessionInfo, - format: EntryTimeFormat, - ix: usize, - highlight_positions: Vec, - cx: &Context, - ) -> AnyElement { - let selected = ix == self.selected_index; - let hovered = Some(ix) == self.hovered_index; - let entry_time = entry.updated_at; - let display_text = match (format, entry_time) { - (EntryTimeFormat::DateAndTime, Some(entry_time)) => { - let now = Utc::now(); - let duration = now.signed_duration_since(entry_time); - let days = duration.num_days(); - - format!("{}d", days) - } - (EntryTimeFormat::TimeOnly, Some(entry_time)) => { - format.format_timestamp(entry_time.timestamp(), self.local_timezone) - } - (_, None) => "—".to_string(), - }; - - let title = thread_title(entry).clone(); - let full_date = entry_time - .map(|time| { - EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) - }) - .unwrap_or_else(|| "Unknown".to_string()); - - h_flex() - .w_full() - .pb_1() - .child( - ListItem::new(ix) - .rounded() - .toggle_state(selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child( - HighlightedLabel::new(thread_title(entry), highlight_positions) - .size(LabelSize::Small) - .truncate(), - ) - .child( - Label::new(display_text) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) - }) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_index = Some(ix); - } else if this.hovered_index == Some(ix) { - this.hovered_index = None; - } - - cx.notify(); - })) - .end_slot::(if hovered && self.supports_delete() { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_thread(ix, cx); - cx.stop_propagation() - })), - ) - } else { - None - }) - .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), - ) - .into_any_element() - } -} - -impl Focusable for ThreadHistory { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.search_editor.focus_handle(cx) - } -} - -impl Render for ThreadHistory { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.is_empty(); - - v_flex() - .key_context("ThreadHistory") - .size_full() - .bg(cx.theme().colors().panel_background) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::remove_selected_thread)) - .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { - this.remove_history(window, cx); - })) - .child( - h_flex() - .h(Tab::container_height(cx)) - .w_full() - .py_1() - .px_2() - .gap_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(self.search_editor.clone()), - ) - .child({ - let view = v_flex() - .id("list-container") - .relative() - .overflow_hidden() - .flex_grow(); - - if has_no_history { - view.justify_center().items_center().child( - Label::new("You don't have any past threads yet.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - } else if self.search_produced_no_matches() { - view.justify_center() - .items_center() - .child(Label::new("No threads match your search.").size(LabelSize::Small)) - } else { - view.child( - uniform_list( - "thread-history", - self.visible_items.len(), - cx.processor(|this, range: Range, window, cx| { - this.render_list_items(range, window, cx) - }), - ) - .p_1() - .pr_4() - .track_scroll(&self.scroll_handle) - .flex_grow(), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - } - }) - .when(!has_no_history && self.supports_delete(), |this| { - this.child( - h_flex() - .p_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .when(!self.confirming_delete_history, |this| { - this.child( - Button::new("delete_history", "Delete All History") - .full_width() - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.prompt_delete_history(window, cx); - })), - ) - }) - .when(self.confirming_delete_history, |this| { - this.w_full() - .gap_2() - .flex_wrap() - .justify_between() - .child( - h_flex() - .flex_wrap() - .gap_1() - .child( - Label::new("Delete all threads?") - .size(LabelSize::Small), - ) - .child( - Label::new("You won't be able to recover them later.") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("cancel_delete", "Cancel") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_delete_history(window, cx); - })), - ) - .child( - Button::new("confirm_delete", "Delete") - .style(ButtonStyle::Tinted(ui::TintColor::Error)) - .color(Color::Error) - .label_size(LabelSize::Small) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action( - Box::new(RemoveHistory), - cx, - ); - })), - ), - ) - }), - ) - }) - } -} - -#[derive(IntoElement)] -pub struct HistoryEntryElement { - entry: AgentSessionInfo, - thread_view: WeakEntity, - selected: bool, - hovered: bool, - supports_delete: bool, - on_hover: Box, -} - -impl HistoryEntryElement { - pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self { - Self { - entry, - thread_view, - selected: false, - hovered: false, - supports_delete: false, - on_hover: Box::new(|_, _, _| {}), - } - } - - pub fn supports_delete(mut self, supports_delete: bool) -> Self { - self.supports_delete = supports_delete; - self - } - - pub fn hovered(mut self, hovered: bool) -> Self { - self.hovered = hovered; - self - } - - pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { - self.on_hover = Box::new(on_hover); - self - } -} - -impl RenderOnce for HistoryEntryElement { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let id = ElementId::Name(self.entry.session_id.0.clone().into()); - let title = thread_title(&self.entry).clone(); - let formatted_time = self - .entry - .updated_at - .map(|timestamp| { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(timestamp); - - if duration.num_days() > 0 { - format!("{}d", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h ago", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m ago", duration.num_minutes()) - } else { - "Just now".to_string() - } - }) - .unwrap_or_else(|| "Unknown".to_string()); - - ListItem::new(id) - .rounded() - .toggle_state(self.selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child(Label::new(title).size(LabelSize::Small).truncate()) - .child( - Label::new(formatted_time) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .on_hover(self.on_hover) - .end_slot::(if (self.hovered || self.selected) && self.supports_delete { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click({ - let thread_view = self.thread_view.clone(); - let session_id = self.entry.session_id.clone(); - - move |_event, _window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.update(cx, |thread_view, cx| { - thread_view.delete_history_entry(&session_id, cx); - }); - } - } - }), - ) - } else { - None - }) - .on_click({ - let thread_view = self.thread_view.clone(); - let entry = self.entry; - - move |_event, window, cx| { - if let Some(workspace) = thread_view - .upgrade() - .and_then(|view| view.read(cx).workspace().upgrade()) - { - if let Some(panel) = workspace.read(cx).panel::(cx) { - panel.update(cx, |panel, cx| { - panel.load_agent_thread( - entry.session_id.clone(), - entry.cwd.clone(), - entry.title.clone(), - window, - cx, - ); - }); - } - } - } - }) - } -} - -#[derive(Clone, Copy)] -pub enum EntryTimeFormat { - DateAndTime, - TimeOnly, -} - -impl EntryTimeFormat { - fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { - let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); - - match self { - EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( - timestamp, - OffsetDateTime::now_utc(), - timezone, - time_format::TimestampFormat::EnhancedAbsolute, - ), - EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), - } - } -} - -impl From for EntryTimeFormat { - fn from(bucket: TimeBucket) -> Self { - match bucket { - TimeBucket::Today => EntryTimeFormat::TimeOnly, - TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, - TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, - TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, - TimeBucket::All => EntryTimeFormat::DateAndTime, - } - } -} - -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -enum TimeBucket { - Today, - Yesterday, - ThisWeek, - PastWeek, - All, -} - -impl TimeBucket { - fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { - if date == reference { - return TimeBucket::Today; - } - - if date == reference - TimeDelta::days(1) { - return TimeBucket::Yesterday; - } - - let week = date.iso_week(); - - if reference.iso_week() == week { - return TimeBucket::ThisWeek; - } - - let last_week = (reference - TimeDelta::days(7)).iso_week(); - - if week == last_week { - return TimeBucket::PastWeek; - } - - TimeBucket::All - } -} - -impl Display for TimeBucket { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TimeBucket::Today => write!(f, "Today"), - TimeBucket::Yesterday => write!(f, "Yesterday"), - TimeBucket::ThisWeek => write!(f, "This Week"), - TimeBucket::PastWeek => write!(f, "Past Week"), - TimeBucket::All => write!(f, "All"), + Task::ready(Ok(())) } } } @@ -1073,7 +244,6 @@ impl Display for TimeBucket { mod tests { use super::*; use acp_thread::AgentSessionListResponse; - use chrono::NaiveDate; use gpui::TestAppContext; use std::{ any::Any, @@ -1246,9 +416,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, _cx| { @@ -1270,9 +438,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); session_list.clear_requested_cursors(); @@ -1307,9 +473,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1340,9 +504,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1371,9 +533,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1416,9 +576,7 @@ mod tests { .with_async_responses(), ); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); session_list.clear_requested_cursors(); @@ -1449,19 +607,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send a title update session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("New Title"), }); cx.run_until_parked(); - // Check that the title was updated history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1486,19 +640,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update that clears the title (null) session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title(None::), }); cx.run_until_parked(); - // Check that the title was cleared history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!(session.unwrap().title, None); @@ -1520,19 +670,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update with no fields set (all undefined) session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new(), }); cx.run_until_parked(); - // Check that the title is unchanged history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1557,12 +703,9 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send multiple updates before the executor runs session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("First Title"), @@ -1573,7 +716,6 @@ mod tests { }); cx.run_until_parked(); - // Check that the final title is "Second Title" (both applied in order) history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1598,12 +740,9 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an info update followed by a refresh session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("Local Update"), @@ -1611,7 +750,6 @@ mod tests { session_list.send_update(SessionListUpdate::Refresh); cx.run_until_parked(); - // The refresh should have fetched from server, getting "Server Title" history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1636,19 +774,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update for an unknown session session_list.send_update(SessionListUpdate::SessionInfo { session_id: acp::SessionId::new("unknown-session"), update: acp::SessionInfoUpdate::new().title("Should Be Ignored"), }); cx.run_until_parked(); - // Check that the known session is unchanged and no crash occurred history.update(cx, |history, _cx| { assert_eq!(history.sessions.len(), 1); assert_eq!( @@ -1657,43 +791,4 @@ mod tests { ); }); } - - #[test] - fn test_time_bucket_from_dates() { - let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); - - let date = today; - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); - - let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); - - let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - // All: not in this week or last week - let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); - - // Test year boundary cases - let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - - let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); - assert_eq!( - TimeBucket::from_dates(new_year, date), - TimeBucket::Yesterday - ); - - let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); - assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); - } } diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..1756fc46ed48e86dc4bf9c78f2c2ef79618ed43b --- /dev/null +++ b/crates/agent_ui/src/thread_history_view.rs @@ -0,0 +1,878 @@ +use crate::thread_history::ThreadHistory; +use crate::{AgentPanel, ConnectionView, RemoveHistory, RemoveSelectedThread}; +use acp_thread::AgentSessionInfo; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, WeakEntity, Window, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, + WithScrollbar, prelude::*, +}; + +const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); + +pub(crate) fn thread_title(entry: &AgentSessionInfo) -> &SharedString { + entry + .title + .as_ref() + .filter(|title| !title.is_empty()) + .unwrap_or(DEFAULT_TITLE) +} + +pub struct ThreadHistoryView { + history: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + confirming_delete_history: bool, + _visible_items_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: AgentSessionInfo, + format: EntryTimeFormat, + }, + SearchResult { + entry: AgentSessionInfo, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&AgentSessionInfo> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +pub enum ThreadHistoryViewEvent { + Open(AgentSessionInfo), +} + +impl EventEmitter for ThreadHistoryView {} + +impl ThreadHistoryView { + pub fn new( + history: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_subscription = cx.observe(&history, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + confirming_delete_history: false, + _subscriptions: vec![search_editor_subscription, history_subscription], + _visible_items_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self.history.read(cx).sessions().to_vec(); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._visible_items_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.session_id == history_entry.session_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_bucket = entry + .updated_at + .map(|timestamp| { + let entry_date = timestamp.with_timezone(&Local).naive_local().date(); + TimeBucket::from_dates(today, entry_date) + }) + .unwrap_or(TimeBucket::All); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.len() == 0 { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryViewEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + if !self.history.read(cx).supports_delete() { + return; + } + let session_id = entry.session_id.clone(); + self.history.update(cx, |history, cx| { + history + .delete_session(&session_id, cx) + .detach_and_log_err(cx); + }); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + if !self.history.read(cx).supports_delete() { + return; + } + self.history.update(cx, |history, cx| { + history.delete_sessions(cx).detach_and_log_err(cx); + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &AgentSessionInfo, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let entry_time = entry.updated_at; + let display_text = match (format, entry_time) { + (EntryTimeFormat::DateAndTime, Some(entry_time)) => { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + let days = duration.num_days(); + + format!("{}d", days) + } + (EntryTimeFormat::TimeOnly, Some(entry_time)) => { + format.format_timestamp(entry_time.timestamp(), self.local_timezone) + } + (_, None) => "—".to_string(), + }; + + let title = thread_title(entry).clone(); + let full_date = entry_time + .map(|time| { + EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) + }) + .unwrap_or_else(|| "Unknown".to_string()); + + let supports_delete = self.history.read(cx).supports_delete(); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(thread_title(entry), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(display_text) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) + }) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + + cx.notify(); + })) + .end_slot::(if hovered && supports_delete { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for ThreadHistoryView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for ThreadHistoryView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history.read(cx).is_empty(); + let supports_delete = self.history.read(cx).supports_delete(); + + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(&self.scroll_handle) + .flex_grow(), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } + }) + .when(!has_no_history && supports_delete, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) + } +} + +#[derive(IntoElement)] +pub struct HistoryEntryElement { + entry: AgentSessionInfo, + thread_view: WeakEntity, + selected: bool, + hovered: bool, + supports_delete: bool, + on_hover: Box, +} + +impl HistoryEntryElement { + pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self { + Self { + entry, + thread_view, + selected: false, + hovered: false, + supports_delete: false, + on_hover: Box::new(|_, _, _| {}), + } + } + + pub fn supports_delete(mut self, supports_delete: bool) -> Self { + self.supports_delete = supports_delete; + self + } + + pub fn hovered(mut self, hovered: bool) -> Self { + self.hovered = hovered; + self + } + + pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.on_hover = Box::new(on_hover); + self + } +} + +impl RenderOnce for HistoryEntryElement { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = ElementId::Name(self.entry.session_id.0.clone().into()); + let title = thread_title(&self.entry).clone(); + let formatted_time = self + .entry + .updated_at + .map(|timestamp| { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(timestamp); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h ago", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m ago", duration.num_minutes()) + } else { + "Just now".to_string() + } + }) + .unwrap_or_else(|| "Unknown".to_string()); + + ListItem::new(id) + .rounded() + .toggle_state(self.selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Label::new(title).size(LabelSize::Small).truncate()) + .child( + Label::new(formatted_time) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(self.on_hover) + .end_slot::(if (self.hovered || self.selected) && self.supports_delete { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let session_id = self.entry.session_id.clone(); + + move |_event, _window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.update(cx, |thread_view, cx| { + thread_view.delete_history_entry(&session_id, cx); + }); + } + } + }), + ) + } else { + None + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let entry = self.entry; + + move |_event, window, cx| { + if let Some(workspace) = thread_view + .upgrade() + .and_then(|view| view.read(cx).workspace().upgrade()) + { + if let Some(panel) = workspace.read(cx).panel::(cx) { + panel.update(cx, |panel, cx| { + panel.load_agent_thread( + entry.session_id.clone(), + entry.cwd.clone(), + entry.title.clone(), + window, + cx, + ); + }); + } + } + } + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(); + + assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today); + + let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, yesterday), + TimeBucket::Yesterday + ); + + let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, this_week), + TimeBucket::ThisWeek + ); + + let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, past_week), + TimeBucket::PastWeek + ); + + let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All); + } +} From 34407d62eaeda4b87c109ad5497328a70553cfae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:31:46 +0100 Subject: [PATCH 32/32] Delete unused workspace dependencies (#51285) Just a small opportunistic cleanup. Release Notes: - N/A --- Cargo.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f650dace84b1b2e6491acf2806077f72000605f5..36e7ca8cc7129af0ed7ab29dc5db338cdf33f7d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -511,7 +511,6 @@ aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" -bincode = "1.2.1" bitflags = "2.6.0" brotli = "8.0.2" bytes = "1.0" @@ -570,7 +569,6 @@ human_bytes = "0.4.1" html5ever = "0.27.0" http = "1.1" http-body = "1.0" -hyper = "0.14" ignore = "0.4.22" image = "0.25.1" imara-diff = "0.1.8" @@ -688,7 +686,6 @@ serde_json_lenient = { version = "0.2", features = [ "raw_value", ] } serde_path_to_error = "0.1.17" -serde_repr = "0.1" serde_urlencoded = "0.7" sha2 = "0.10" shellexpand = "2.1.0" @@ -719,7 +716,6 @@ time = { version = "0.3", features = [ ] } tiny_http = "0.8" tokio = { version = "1" } -tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } tokio-socks = { version = "0.5.2", default-features = false, features = [ "futures-io", "tokio",