From ca7de0fc5815e26c6836b373bb0c2cc7137baa7e Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:29:07 -0300 Subject: [PATCH 01/22] sidebar: Add design adjustments to the thread import feature (#52858) - Adds a new icon for thread import - Iterate on the design to access the import modal: it's now through a button in the sidebar's footer, which only appears when the archive view is toggled - Fixed an issue where clicking on checkboxes within the import modal's list items wouldn't do anything Release Notes: - N/A --- assets/icons/thread_import.svg | 5 ++ crates/agent_ui/src/thread_import.rs | 49 +++++--------- crates/agent_ui/src/threads_archive_view.rs | 75 +-------------------- crates/icons/src/icons.rs | 1 + crates/sidebar/src/sidebar.rs | 43 ++++++------ crates/ui/src/components/list/list_item.rs | 12 ++-- 6 files changed, 52 insertions(+), 133 deletions(-) create mode 100644 assets/icons/thread_import.svg diff --git a/assets/icons/thread_import.svg b/assets/icons/thread_import.svg new file mode 100644 index 0000000000000000000000000000000000000000..a56b5a7cccc09c5795bfadff06f06d15833232f3 --- /dev/null +++ b/assets/icons/thread_import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index 9dd6b5efa0ae1cd3bc19dc6ae6a287218de8c668..f5fc89d3df4991ff5186e2af6d73ad6a840c09a1 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -121,18 +121,6 @@ impl ThreadImportModal { .collect() } - fn set_agent_checked(&mut self, agent_id: AgentId, state: ToggleState, cx: &mut Context) { - match state { - ToggleState::Selected => { - self.unchecked_agents.remove(&agent_id); - } - ToggleState::Unselected | ToggleState::Indeterminate => { - self.unchecked_agents.insert(agent_id); - } - } - cx.notify(); - } - fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context) { if self.unchecked_agents.contains(&agent_id) { self.unchecked_agents.remove(&agent_id); @@ -283,6 +271,11 @@ impl ModalView for ThreadImportModal {} impl Render for ThreadImportModal { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_agents = !self.agent_entries.is_empty(); + let disabled_import_thread = self.is_importing + || !has_agents + || self.unchecked_agents.len() == self.agent_entries.len(); + let agent_rows = self .agent_entries .iter() @@ -295,6 +288,7 @@ impl Render for ThreadImportModal { .rounded() .spacing(ListItemSpacing::Sparse) .focused(is_focused) + .disabled(self.is_importing) .child( h_flex() .w_full() @@ -311,22 +305,14 @@ impl Render for ThreadImportModal { }) .child(Label::new(entry.display_name.clone())), ) - .end_slot( - Checkbox::new( - ("thread-import-agent-checkbox", ix), - if is_checked { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .on_click({ - let agent_id = entry.agent_id.clone(); - cx.listener(move |this, state: &ToggleState, _window, cx| { - this.set_agent_checked(agent_id.clone(), *state, cx); - }) - }), - ) + .end_slot(Checkbox::new( + ("thread-import-agent-checkbox", ix), + if is_checked { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + )) .on_click({ let agent_id = entry.agent_id.clone(); cx.listener(move |this, _event, _window, cx| { @@ -336,11 +322,6 @@ impl Render for ThreadImportModal { }) .collect::>(); - let has_agents = !self.agent_entries.is_empty(); - let disabled_import_thread = self.is_importing - || !has_agents - || self.unchecked_agents.len() == self.agent_entries.len(); - v_flex() .id("thread-import-modal") .key_context("ThreadImportModal") @@ -373,7 +354,7 @@ impl Render for ThreadImportModal { v_flex() .id("thread-import-agent-list") .max_h(rems_from_px(320.)) - .pb_2() + .pb_1() .overflow_y_scroll() .when(has_agents, |this| this.children(agent_rows)) .when(!has_agents, |this| { diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 445d86c9ad4e37fa8b2502a754a5264cd1d4dc45..74a93129d387e0aaac6e7092d9e086dd64e369f7 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1,5 +1,5 @@ use crate::agent_connection_store::AgentConnectionStore; -use crate::thread_import::{AcpThreadImportOnboarding, ThreadImportModal}; + use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use crate::{Agent, RemoveSelectedThread}; @@ -15,15 +15,13 @@ use gpui::{ }; use itertools::Itertools as _; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use project::{AgentId, AgentRegistryStore, AgentServerStore}; +use project::{AgentId, AgentServerStore}; use settings::Settings as _; use theme::ActiveTheme; use ui::ThreadItem; use ui::{ Divider, KeyBinding, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, }; -use util::ResultExt; -use workspace::{MultiWorkspace, Workspace}; use zed_actions::agents_sidebar::FocusSidebarFilter; use zed_actions::editor::{MoveDown, MoveUp}; @@ -114,18 +112,12 @@ pub struct ThreadsArchiveView { _refresh_history_task: Task<()>, agent_connection_store: WeakEntity, agent_server_store: WeakEntity, - agent_registry_store: WeakEntity, - workspace: WeakEntity, - multi_workspace: WeakEntity, } impl ThreadsArchiveView { pub fn new( agent_connection_store: WeakEntity, agent_server_store: WeakEntity, - agent_registry_store: WeakEntity, - workspace: WeakEntity, - multi_workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -184,11 +176,8 @@ impl ThreadsArchiveView { thread_metadata_store_subscription, ], _refresh_history_task: Task::ready(()), - agent_registry_store, agent_connection_store, agent_server_store, - workspace, - multi_workspace, }; this.update_items(cx); @@ -550,43 +539,6 @@ impl ThreadsArchiveView { .detach_and_log_err(cx); } - fn should_render_acp_import_onboarding(&self, cx: &App) -> bool { - let has_external_agents = self - .agent_server_store - .upgrade() - .map(|store| store.read(cx).has_external_agents()) - .unwrap_or(false); - - has_external_agents && !AcpThreadImportOnboarding::dismissed(cx) - } - - fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context) { - let Some(agent_server_store) = self.agent_server_store.upgrade() else { - return; - }; - let Some(agent_registry_store) = self.agent_registry_store.upgrade() else { - return; - }; - - let workspace_handle = self.workspace.clone(); - let multi_workspace = self.multi_workspace.clone(); - - self.workspace - .update(cx, |workspace, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - ThreadImportModal::new( - agent_server_store, - agent_registry_store, - workspace_handle.clone(), - multi_workspace.clone(), - window, - cx, - ) - }); - }) - .log_err(); - } - fn render_header(&self, window: &Window, cx: &mut Context) -> impl IntoElement { let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); let sidebar_on_left = matches!( @@ -729,28 +681,5 @@ impl Render for ThreadsArchiveView { .size_full() .child(self.render_header(window, cx)) .child(content) - .when(!self.should_render_acp_import_onboarding(cx), |this| { - this.child( - div() - .w_full() - .p_1p5() - .border_t_1() - .border_color(cx.theme().colors().border) - .child( - Button::new("import-acp", "Import ACP Threads") - .full_width() - .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border)) - .label_size(LabelSize::Small) - .start_icon( - Icon::new(IconName::ArrowDown) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.show_thread_import_modal(window, cx); - })), - ), - ) - }) } } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 400b2a22bc6071b62c6ce22a2b1bf1053c8cf871..ad7faa6664d1ddc618c8984781a244af7dda6c97 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -240,6 +240,7 @@ pub enum IconName { ThinkingModeOff, Thread, ThreadFromSummary, + ThreadImport, ThreadsSidebarLeftClosed, ThreadsSidebarLeftOpen, ThreadsSidebarRightClosed, diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 450a2674e0062d917003758c41c445048ee603f7..b1257b4c79c2ef193ec4594139cd1f57b93a5666 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -3309,10 +3309,24 @@ impl Sidebar { } fn render_sidebar_bottom_bar(&mut self, cx: &mut Context) -> impl IntoElement { - let on_right = self.side(cx) == SidebarSide::Right; let is_archive = matches!(self.view, SidebarView::Archive(..)); + let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx); + let on_right = self.side(cx) == SidebarSide::Right; + let action_buttons = h_flex() .gap_1() + .when(on_right, |this| this.flex_row_reverse()) + .when(show_import_button, |this| { + this.child( + IconButton::new("thread-import", IconName::ThreadImport) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Import ACP Threads")) + .on_click(cx.listener(|this, _, window, cx| { + this.show_archive(window, cx); + this.show_thread_import_modal(window, cx); + })), + ) + }) .child( IconButton::new("archive", IconName::Archive) .icon_size(IconSize::Small) @@ -3325,21 +3339,16 @@ impl Sidebar { })), ) .child(self.render_recent_projects_button(cx)); - let border_color = cx.theme().colors().border; - let toggle_button = self.render_sidebar_toggle_button(cx); - let bar = h_flex() + h_flex() .p_1() .gap_1() + .when(on_right, |this| this.flex_row_reverse()) .justify_between() .border_t_1() - .border_color(border_color); - - if on_right { - bar.child(action_buttons).child(toggle_button) - } else { - bar.child(toggle_button).child(action_buttons) - } + .border_color(cx.theme().colors().border) + .child(self.render_sidebar_toggle_button(cx)) + .child(action_buttons) } fn active_workspace(&self, cx: &App) -> Option> { @@ -3409,7 +3418,7 @@ impl Sidebar { v_flex() .min_w_0() .w_full() - .p_1p5() + .p_2() .border_t_1() .border_color(cx.theme().colors().border) .bg(linear_gradient( @@ -3437,8 +3446,8 @@ impl Sidebar { .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border)) .label_size(LabelSize::Small) .start_icon( - Icon::new(IconName::ArrowDown) - .size(IconSize::XSmall) + Icon::new(IconName::ThreadImport) + .size(IconSize::Small) .color(Color::Muted), ) .on_click(cx.listener(|this, _, window, cx| { @@ -3467,9 +3476,6 @@ impl Sidebar { let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else { return; }; - let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else { - return; - }; let agent_server_store = active_workspace .read(cx) @@ -3484,9 +3490,6 @@ impl Sidebar { ThreadsArchiveView::new( agent_connection_store.clone(), agent_server_store.clone(), - agent_registry_store.downgrade(), - active_workspace.downgrade(), - self.multi_workspace.clone(), window, cx, ) diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 693cf3d52e34369d04db445d1ddac765691fb429..0d3efc024f1a202947fd0e7b0dab917c40ae8337 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -234,9 +234,9 @@ impl RenderOnce for ListItem { this.ml(self.indent_level as f32 * self.indent_step_size) .px(DynamicSpacing::Base04.rems(cx)) }) - .when(!self.inset && !self.disabled, |this| { + .when(!self.inset, |this| { this.when_some(self.focused, |this, focused| { - if focused { + if focused && !self.disabled { this.border_1() .when(self.docked_right, |this| this.border_r_2()) .border_color(cx.theme().colors().border_focused) @@ -244,7 +244,7 @@ impl RenderOnce for ListItem { this.border_1() } }) - .when(self.selectable, |this| { + .when(self.selectable && !self.disabled, |this| { this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) .active(|style| style.bg(cx.theme().colors().ghost_element_active)) .when(self.outlined, |this| this.rounded_sm()) @@ -268,16 +268,16 @@ impl RenderOnce for ListItem { ListItemSpacing::ExtraDense => this.py_neg_px(), ListItemSpacing::Sparse => this.py_1(), }) - .when(self.inset && !self.disabled, |this| { + .when(self.inset, |this| { this.when_some(self.focused, |this, focused| { - if focused { + if focused && !self.disabled { this.border_1() .border_color(cx.theme().colors().border_focused) } else { this.border_1() } }) - .when(self.selectable, |this| { + .when(self.selectable && !self.disabled, |this| { this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) .active(|style| style.bg(cx.theme().colors().ghost_element_active)) .when(self.selected, |this| { From a12601f8631070ee2474783b9bfb61bca539a574 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Tue, 31 Mar 2026 16:23:21 -0700 Subject: [PATCH 02/22] workspace: Break workspace tests into their own file (#52854) Splits the tests out of `multi_workspace.rs` into a dedicated `multi_workspace_tests.rs` file for better organization. Release Notes: - N/A Co-authored-by: Max Brunsfeld --- crates/workspace/src/multi_workspace.rs | 175 ------------------ crates/workspace/src/multi_workspace_tests.rs | 172 +++++++++++++++++ crates/workspace/src/workspace.rs | 2 + 3 files changed, 174 insertions(+), 175 deletions(-) create mode 100644 crates/workspace/src/multi_workspace_tests.rs diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 862f7c7b267721833fa395e501b604d30745a1b7..10a5ce70ead2d5aea7cc21a9af53ee9f216859c3 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1113,178 +1113,3 @@ impl Render for MultiWorkspace { ) } } - -#[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_settings::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.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.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.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.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.sidebar_open(), - "Sidebar should open when toggled after re-enabling AI" - ); - }); - } - - #[gpui::test] - async fn test_replace(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - let project_a = Project::test(fs.clone(), [], cx).await; - let project_b = Project::test(fs.clone(), [], cx).await; - let project_c = Project::test(fs.clone(), [], cx).await; - let project_d = Project::test(fs.clone(), [], cx).await; - - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - - let workspace_a_id = - multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id()); - - // Replace the only workspace (single-workspace case). - let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx)); - mw.replace(workspace.clone(), &*window, cx); - workspace - }); - - multi_workspace.read_with(cx, |mw, _cx| { - assert_eq!(mw.workspaces().len(), 1); - assert_eq!( - mw.workspaces()[0].entity_id(), - workspace_b.entity_id(), - "slot should now be project_b" - ); - assert_ne!( - mw.workspaces()[0].entity_id(), - workspace_a_id, - "project_a should be gone" - ); - }); - - // Add project_c as a second workspace, then replace it with project_d. - let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_c.clone(), window, cx) - }); - - multi_workspace.read_with(cx, |mw, _cx| { - assert_eq!(mw.workspaces().len(), 2); - assert_eq!(mw.active_workspace_index(), 1); - }); - - let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx)); - mw.replace(workspace.clone(), &*window, cx); - workspace - }); - - multi_workspace.read_with(cx, |mw, _cx| { - assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces"); - assert_eq!(mw.active_workspace_index(), 1); - assert_eq!( - mw.workspaces()[1].entity_id(), - workspace_d.entity_id(), - "active slot should now be project_d" - ); - assert_ne!( - mw.workspaces()[1].entity_id(), - workspace_c.entity_id(), - "project_c should be gone" - ); - }); - - // Replace with workspace_b which is already in the list — should just switch. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.replace(workspace_b.clone(), &*window, cx); - }); - - multi_workspace.read_with(cx, |mw, _cx| { - assert_eq!( - mw.workspaces().len(), - 2, - "no workspace should be added or removed" - ); - assert_eq!( - mw.active_workspace_index(), - 0, - "should have switched to workspace_b" - ); - }); - } -} diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..50161121719ec7b2835fd11e389f24860e57d8f5 --- /dev/null +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -0,0 +1,172 @@ +use super::*; +use feature_flags::FeatureFlagAppExt; +use fs::FakeFs; +use gpui::TestAppContext; +use project::DisableAiSettings; +use settings::SettingsStore; + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::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.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.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.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.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.sidebar_open(), + "Sidebar should open when toggled after re-enabling AI" + ); + }); +} + +#[gpui::test] +async fn test_replace(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs.clone(), [], cx).await; + let project_c = Project::test(fs.clone(), [], cx).await; + let project_d = Project::test(fs.clone(), [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a_id = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id()); + + // Replace the only workspace (single-workspace case). + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx)); + mw.replace(workspace.clone(), &*window, cx); + workspace + }); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().len(), 1); + assert_eq!( + mw.workspaces()[0].entity_id(), + workspace_b.entity_id(), + "slot should now be project_b" + ); + assert_ne!( + mw.workspaces()[0].entity_id(), + workspace_a_id, + "project_a should be gone" + ); + }); + + // Add project_c as a second workspace, then replace it with project_d. + let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_c.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().len(), 2); + assert_eq!(mw.active_workspace_index(), 1); + }); + + let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx)); + mw.replace(workspace.clone(), &*window, cx); + workspace + }); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces"); + assert_eq!(mw.active_workspace_index(), 1); + assert_eq!( + mw.workspaces()[1].entity_id(), + workspace_d.entity_id(), + "active slot should now be project_d" + ); + assert_ne!( + mw.workspaces()[1].entity_id(), + workspace_c.entity_id(), + "project_c should be gone" + ); + }); + + // Replace with workspace_b which is already in the list — should just switch. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.replace(workspace_b.clone(), &*window, cx); + }); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!( + mw.workspaces().len(), + 2, + "no workspace should be added or removed" + ); + assert_eq!( + mw.active_workspace_index(), + 0, + "should have switched to workspace_b" + ); + }); +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 33d1befe38e3b48a39377547bd433398b37d6a77..ae05c2c59012b2caf217ac54a80b377aee87f09d 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -5,6 +5,8 @@ pub mod invalid_item_view; pub mod item; mod modal_layer; mod multi_workspace; +#[cfg(test)] +mod multi_workspace_tests; pub mod notifications; pub mod pane; pub mod pane_group; From 20b140664d3b003ef4b23c03384f40c7c57aea06 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 31 Mar 2026 17:10:29 -0700 Subject: [PATCH 03/22] Fix markdown table rendering in the agent panel (#52864) This PR reverts part of https://github.com/zed-industries/zed/pull/50839, as it was causing bad clipping in the agent panel Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A --- crates/markdown/src/markdown.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 7b95688df54610f92b6960d9afc3037bf484b8ed..024e377c2538214c9579c8f025250e2166cf7ace 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1609,23 +1609,18 @@ impl Element for MarkdownElement { builder.table.start(alignments.clone()); let column_count = alignments.len(); - builder.push_div( - div().flex().flex_col().items_start(), - range, - markdown_end, - ); builder.push_div( div() .id(("table", range.start)) - .min_w_0() .grid() .grid_cols(column_count as u16) .when(self.style.table_columns_min_size, |this| { this.grid_cols_min_content(column_count as u16) }) .when(!self.style.table_columns_min_size, |this| { - this.grid_cols_max_content(column_count as u16) + this.grid_cols(column_count as u16) }) + .w_full() .mb_2() .border(px(1.5)) .border_color(cx.theme().colors().border) @@ -1770,7 +1765,6 @@ impl Element for MarkdownElement { } } MarkdownTagEnd::Table => { - builder.pop_div(); builder.pop_div(); builder.table.end(); } From 6837c8aa6bc314cd02e44f71e9377f3eada7120f Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:20:24 -0400 Subject: [PATCH 04/22] git_graph: Fix empty search case (#52845) This fixes a bug where search would match all commits if there was an empty query instead of setting the query to None Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: Remco Smits --- crates/git_graph/src/git_graph.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index b971566075181350453b28bf9909371e51436021..d169ba686098dddd4881915ece11c8a97148affa 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1324,6 +1324,12 @@ impl GitGraph { editor.set_text_style_refinement(Default::default()); }); + if query.as_str().is_empty() { + self.search_state.state = QueryState::Empty; + cx.notify(); + return; + } + let (request_tx, request_rx) = smol::channel::unbounded::(); repo.update(cx, |repo, cx| { From 22c123868af5109309c36cff293e525633039d90 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:23:42 -0300 Subject: [PATCH 05/22] ui: Improve the `end_hover` method API in the `ListItem` component (#52862) This PR changes the API for the `ListItem`'s `end_hover` slot so that whatever is in there is always part of the flex stack, as opposed to an absolutely-positioned element. Additionally, I'm also improving the API for swapping content between the default state and the hovered state (e.g., list items where by default we render X, but when you hover, we show something else). Lastly, I'm adding buttons to some Git picker items that were only previously available through modal footer buttons. Release Notes: - N/A --- assets/icons/maximize_alt.svg | 6 ++ crates/agent_ui/src/config_options.rs | 2 +- .../src/ui/model_selector_components.rs | 2 +- crates/git_ui/src/branch_picker.rs | 18 ++---- crates/git_ui/src/stash_picker.rs | 54 +++++++++++----- crates/git_ui/src/worktree_picker.rs | 30 +++++++-- crates/icons/src/icons.rs | 1 + crates/recent_projects/src/recent_projects.rs | 27 ++------ crates/recent_projects/src/remote_servers.rs | 40 ++++++------ crates/rules_library/src/rules_library.rs | 2 +- crates/tab_switcher/src/tab_switcher.rs | 2 +- crates/tasks_ui/src/modal.rs | 2 +- crates/ui/src/components/list/list_item.rs | 63 ++++++++++++------- 13 files changed, 144 insertions(+), 105 deletions(-) create mode 100644 assets/icons/maximize_alt.svg diff --git a/assets/icons/maximize_alt.svg b/assets/icons/maximize_alt.svg new file mode 100644 index 0000000000000000000000000000000000000000..b8b8705f902c2469ed959f93f89ca3caf3b8fc51 --- /dev/null +++ b/assets/icons/maximize_alt.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index b8cf7e5d57921c7710392911829fc2b5045a0f90..44c0baa232222c0ba7c1d54acdecaabacfa85f12 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -650,7 +650,7 @@ impl PickerDelegate for ConfigOptionPickerDelegate { .end_slot(div().pr_2().when(is_selected, |this| { this.child(Icon::new(IconName::Check).color(Color::Accent)) })) - .end_hover_slot(div().pr_1p5().child({ + .end_slot_on_hover(div().pr_1p5().child({ let (icon, color, tooltip) = if is_favorite { (IconName::StarFilled, Color::Accent, "Unfavorite") } else { diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs index 01ba6c4511854e83b97b1fc053e41e5d0e82ff1e..88bf546a0e7beef53c8043fd04f8e6e9e5e92c88 100644 --- a/crates/agent_ui/src/ui/model_selector_components.rs +++ b/crates/agent_ui/src/ui/model_selector_components.rs @@ -160,7 +160,7 @@ impl RenderOnce for ModelSelectorListItem { .end_slot(div().pr_2().when(self.is_selected, |this| { this.child(Icon::new(IconName::Check).color(Color::Accent)) })) - .end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, { + .end_slot_on_hover(div().pr_1p5().when_some(self.on_toggle_favorite, { |this, handle_click| { let (icon, color, tooltip) = if is_favorite { (IconName::StarFilled, Color::Accent, "Unfavorite Model") diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 438df6839949d46d3ba8e0509995beb1300b7c80..83c8119a077ac1c024dbb3b3df948f762b072ec1 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1087,13 +1087,8 @@ impl PickerDelegate for BranchListDelegate { ), ) .when(!is_new_items && !is_head_branch, |this| { - this.map(|this| { - if self.selected_index() == ix { - this.end_slot(deleted_branch_icon(ix)) - } else { - this.end_hover_slot(deleted_branch_icon(ix)) - } - }) + this.end_slot(deleted_branch_icon(ix)) + .show_end_slot_on_hover() }) .when_some( if is_new_items { @@ -1102,13 +1097,8 @@ impl PickerDelegate for BranchListDelegate { None }, |this, create_from_default_button| { - this.map(|this| { - if self.selected_index() == ix { - this.end_slot(create_from_default_button) - } else { - this.end_hover_slot(create_from_default_button) - } - }) + this.end_slot(create_from_default_button) + .show_end_slot_on_hover() }, ), ) diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index 9987190f45b73f3f1132ce1295de6f412022abe2..2d3515e833e4d353c323f533f1f0f39bb1d76561 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -501,16 +501,39 @@ impl PickerDelegate for StashListDelegate { .size(LabelSize::Small), ); - let focus_handle = self.focus_handle.clone(); + let view_button = { + let focus_handle = self.focus_handle.clone(); + IconButton::new(("view-stash", ix), IconName::Eye) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in("View Stash", &ShowStashItem, &focus_handle, cx) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.delegate.show_stash_at(ix, window, cx); + })) + }; + + let pop_button = { + let focus_handle = self.focus_handle.clone(); + IconButton::new(("pop-stash", ix), IconName::MaximizeAlt) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in("Pop Stash", &menu::SecondaryConfirm, &focus_handle, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); + }) + }; - let drop_button = |entry_ix: usize| { - IconButton::new(("drop-stash", entry_ix), IconName::Trash) + let drop_button = { + let focus_handle = self.focus_handle.clone(); + IconButton::new(("drop-stash", ix), IconName::Trash) .icon_size(IconSize::Small) .tooltip(move |_, cx| { Tooltip::for_action_in("Drop Stash", &DropStashItem, &focus_handle, cx) }) .on_click(cx.listener(move |this, _, window, cx| { - this.delegate.drop_stash_at(entry_ix, window, cx); + this.delegate.drop_stash_at(ix, window, cx); })) }; @@ -530,17 +553,14 @@ impl PickerDelegate for StashListDelegate { ) .child(div().w_full().child(stash_label).child(branch_info)), ) - .tooltip(Tooltip::text(format!( - "stash@{{{}}}", - entry_match.entry.index - ))) - .map(|this| { - if selected { - this.end_slot(drop_button(ix)) - } else { - this.end_hover_slot(drop_button(ix)) - } - }), + .end_slot( + h_flex() + .gap_0p5() + .child(view_button) + .child(pop_button) + .child(drop_button), + ) + .show_end_slot_on_hover(), ) } @@ -549,6 +569,10 @@ impl PickerDelegate for StashListDelegate { } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + if self.matches.is_empty() { + return None; + } + let focus_handle = self.focus_handle.clone(); Some( diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 3e14b56f9bf4a95452855bc6cbef6f764e2b3530..c3e2259e411c7a3a56a36b92735f8d5e014e53d7 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -884,12 +884,30 @@ impl PickerDelegate for WorktreeListDelegate { } })), ) - .when(can_delete, |this| { - if selected { - this.end_slot(delete_button(ix)) - } else { - this.end_hover_slot(delete_button(ix)) - } + .when(!entry.is_new, |this| { + let focus_handle = self.focus_handle.clone(); + let open_in_new_window_button = + IconButton::new(("open-new-window", ix), IconName::ArrowUpRight) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Open in New Window", + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + }) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); + }); + + this.end_slot( + h_flex() + .gap_0p5() + .child(open_in_new_window_button) + .when(can_delete, |this| this.child(delete_button(ix))), + ) + .show_end_slot_on_hover() }), ) } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index ad7faa6664d1ddc618c8984781a244af7dda6c97..6929ae4e4ca8ca0ee00c9793c948892043dd6dd6 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -174,6 +174,7 @@ pub enum IconName { LockOutlined, MagnifyingGlass, Maximize, + MaximizeAlt, Menu, MenuAltTemp, Mic, diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 4dc06036ef8416fd859cc815ab090ba5896c0040..22987f6c56669e1972a9bfc940449991d9f55642 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1264,13 +1264,8 @@ impl PickerDelegate for RecentProjectsDelegate { this.tooltip(Tooltip::text(path.to_string_lossy().to_string())) }), ) - .map(|el| { - if self.selected_index == ix { - el.end_slot(secondary_actions) - } else { - el.end_hover_slot(secondary_actions) - } - }) + .end_slot(secondary_actions) + .show_end_slot_on_hover() .into_any_element(), ) } @@ -1363,13 +1358,8 @@ impl PickerDelegate for RecentProjectsDelegate { }) .tooltip(Tooltip::text(tooltip_path)), ) - .map(|el| { - if self.selected_index == ix { - el.end_slot(secondary_actions) - } else { - el.end_hover_slot(secondary_actions) - } - }) + .end_slot(secondary_actions) + .show_end_slot_on_hover() .into_any_element(), ) } @@ -1503,13 +1493,8 @@ impl PickerDelegate for RecentProjectsDelegate { }) .tooltip(Tooltip::text(tooltip_path)), ) - .map(|el| { - if self.selected_index == ix { - el.end_slot(secondary_actions) - } else { - el.end_hover_slot(secondary_actions) - } - }) + .end_slot(secondary_actions) + .show_end_slot_on_hover() .into_any_element(), ) } diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index f7054687579155d4895ae191de1b7fa7cd14fbf6..26592a8035d50caa4e267a5478d5aceb9fba6e3e 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1621,23 +1621,24 @@ impl RemoteServerProjects { })) .tooltip(Tooltip::text(project.paths.join("\n"))) .when(is_from_zed, |server_list_item| { - server_list_item.end_hover_slot::(Some( - div() - .mr_2() - .child({ - let project = project.clone(); - // Right-margin to offset it from the Scrollbar - IconButton::new("remove-remote-project", IconName::Trash) - .icon_size(IconSize::Small) - .shape(IconButtonShape::Square) - .size(ButtonSize::Large) - .tooltip(Tooltip::text("Delete Remote Project")) - .on_click(cx.listener(move |this, _, _, cx| { - this.delete_remote_project(server_ix, &project, cx) - })) - }) - .into_any_element(), - )) + server_list_item + .end_slot( + div() + .mr_2() + .child({ + let project = project.clone(); + IconButton::new("remove-remote-project", IconName::Trash) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .size(ButtonSize::Large) + .tooltip(Tooltip::text("Delete Remote Project")) + .on_click(cx.listener(move |this, _, _, cx| { + this.delete_remote_project(server_ix, &project, cx) + })) + }) + .into_any_element(), + ) + .show_end_slot_on_hover() }), ) } @@ -2413,9 +2414,8 @@ impl RemoteServerProjects { .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Copy).color(Color::Muted)) .child(Label::new("Copy Server Address")) - .end_hover_slot( - Label::new(connection_string.clone()).color(Color::Muted), - ) + .end_slot(Label::new(connection_string.clone()).color(Color::Muted)) + .show_end_slot_on_hover() .on_click({ let connection_string = connection_string.clone(); move |_, _, cx| { diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 23e7b83772f755089c49f824719af389ec589bd9..7e5a56f22d48c4d51f60d7d200dc8384582beb23 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -389,7 +389,7 @@ impl PickerDelegate for RulePickerDelegate { })) })) .when(!prompt_id.is_built_in(), |this| { - this.end_hover_slot( + this.end_slot_on_hover( h_flex() .child( IconButton::new("delete-rule", IconName::Trash) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 0fb13c85d21797e4d57728c88fc8bb014a898f78..d1e19ea4faee8d8259d06e2c24875faac7a0117c 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -875,7 +875,7 @@ impl PickerDelegate for TabSwitcherDelegate { el.end_slot::(close_button) } else { el.end_slot::(indicator) - .end_hover_slot::(close_button) + .end_slot_on_hover::(close_button) } }), ) diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 34f0cd809692d649bcfbabb7952f3075618ead04..285a07c9562849b26b4cbba3de3979614384d875 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -570,7 +570,7 @@ impl PickerDelegate for TasksModalDelegate { Tooltip::simple("Delete Previously Scheduled Task", cx) }), ); - item.end_hover_slot(delete_button) + item.end_slot_on_hover(delete_button) } else { item } diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 0d3efc024f1a202947fd0e7b0dab917c40ae8337..9a764efd58cfd3365d92e534a715a0f23ce46e90 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -14,6 +14,14 @@ pub enum ListItemSpacing { Sparse, } +#[derive(Default)] +enum EndSlotVisibility { + #[default] + Always, + OnHover, + SwapOnHover(AnyElement), +} + #[derive(IntoElement, RegisterComponent)] pub struct ListItem { id: ElementId, @@ -28,9 +36,7 @@ pub struct ListItem { /// A slot for content that appears after the children, usually on the other side of the header. /// This might be a button, a disclosure arrow, a face pile, etc. end_slot: Option, - /// A slot for content that appears on hover after the children - /// It will obscure the `end_slot` when visible. - end_hover_slot: Option, + end_slot_visibility: EndSlotVisibility, toggle: Option, inset: bool, on_click: Option>, @@ -61,7 +67,7 @@ impl ListItem { indent_step_size: px(12.), start_slot: None, end_slot: None, - end_hover_slot: None, + end_slot_visibility: EndSlotVisibility::default(), toggle: None, inset: false, on_click: None, @@ -165,8 +171,14 @@ impl ListItem { self } - pub fn end_hover_slot(mut self, end_hover_slot: impl Into>) -> Self { - self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element); + pub fn end_slot_on_hover(mut self, end_slot_on_hover: E) -> Self { + self.end_slot_visibility = + EndSlotVisibility::SwapOnHover(end_slot_on_hover.into_any_element()); + self + } + + pub fn show_end_slot_on_hover(mut self) -> Self { + self.end_slot_visibility = EndSlotVisibility::OnHover; self } @@ -338,28 +350,31 @@ impl RenderOnce for ListItem { .children(self.start_slot) .children(self.children), ) + .when(self.end_slot.is_some(), |this| this.justify_between()) .when_some(self.end_slot, |this, end_slot| { - this.justify_between().child( - h_flex() + this.child(match self.end_slot_visibility { + EndSlotVisibility::Always => { + h_flex().flex_shrink().overflow_hidden().child(end_slot) + } + EndSlotVisibility::OnHover => h_flex() .flex_shrink() .overflow_hidden() - .when(self.end_hover_slot.is_some(), |this| { - this.visible() - .group_hover("list_item", |this| this.invisible()) - }) - .child(end_slot), - ) - }) - .when_some(self.end_hover_slot, |this, end_hover_slot| { - this.child( - h_flex() - .h_full() - .absolute() - .right(DynamicSpacing::Base06.rems(cx)) - .top_0() .visible_on_hover("list_item") - .child(end_hover_slot), - ) + .child(end_slot), + EndSlotVisibility::SwapOnHover(hover_slot) => h_flex() + .relative() + .flex_shrink() + .child(h_flex().visible_on_hover("list_item").child(hover_slot)) + .child( + h_flex() + .absolute() + .inset_0() + .justify_end() + .overflow_hidden() + .group_hover("list_item", |this| this.invisible()) + .child(end_slot), + ), + }) }), ) } From 3b6252ca8048afba195bf2c3071869198bdf7d2f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 31 Mar 2026 18:14:18 -0700 Subject: [PATCH 06/22] Bump tree-sitter for fix to wasm loading of grammars w/ reserved words (#52856) This PR bumps Tree-sitter for this crash fix https://github.com/tree-sitter/tree-sitter/pull/5475 Release Notes: - Fixed a crash that could occasionally occur when parsing files using certain language extensions --- Cargo.lock | 455 +++++++++--------- Cargo.toml | 6 +- crates/extension_host/src/wasm_host.rs | 47 +- crates/extension_host/src/wasm_host/wit.rs | 8 +- .../src/wasm_host/wit/since_v0_0_1.rs | 14 +- .../src/wasm_host/wit/since_v0_0_4.rs | 14 +- .../src/wasm_host/wit/since_v0_0_6.rs | 14 +- .../src/wasm_host/wit/since_v0_1_0.rs | 14 +- .../src/wasm_host/wit/since_v0_2_0.rs | 14 +- .../src/wasm_host/wit/since_v0_3_0.rs | 14 +- .../src/wasm_host/wit/since_v0_4_0.rs | 14 +- .../src/wasm_host/wit/since_v0_5_0.rs | 14 +- .../src/wasm_host/wit/since_v0_6_0.rs | 14 +- .../src/wasm_host/wit/since_v0_8_0.rs | 14 +- 14 files changed, 366 insertions(+), 290 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38dcf369b3739f9087b574489666f4f1dfa012e0..f68704cb6ef68887b102f7f6a1a37c0fe694f662 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,22 +111,13 @@ dependencies = [ "workspace", ] -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli 0.31.1", -] - [[package]] name = "addr2line" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli 0.32.3", + "gimli", ] [[package]] @@ -674,7 +665,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object 0.37.3", + "object", ] [[package]] @@ -1821,11 +1812,11 @@ version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ - "addr2line 0.25.1", + "addr2line", "cfg-if", "libc", "miniz_oxide", - "object 0.37.3", + "object", "rustc-demangle", "windows-link 0.2.1", ] @@ -3818,36 +3809,36 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5023e06632d8f351c2891793ccccfe4aef957954904392434038745fb6f1f68" +checksum = "ba33ddc4e157cb1abe9da6c821e8824f99e56d057c2c22536850e0141f281d61" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c4012b4c8c1f6eb05c0a0a540e3e1ee992631af51aa2bbb3e712903ce4fd65" +checksum = "69b23dd6ea360e6fb28a3f3b40b7f126509668f58076a4729b2cfd656f26a0ad" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6d883b4942ef3a7104096b8bc6f2d1a41393f159ac8de12aed27b25d67f895" +checksum = "a9d81afcee8fe27ee2536987df3fadcb2e161af4edb7dbe3ef36838d0ce74382" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7b2ee9eec6ca8a716d900d5264d678fb2c290c58c46c8da7f94ee268175d17" +checksum = "fb33595f1279fe7af03b28245060e9085caf98b10ed3137461a85796eb83972a" dependencies = [ "serde", "serde_derive", @@ -3855,9 +3846,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeda0892577afdce1ac2e9a983a55f8c5b87a59334e1f79d8f735a2d7ba4f4b4" +checksum = "0230a6ac0660bfe31eb244cbb43dcd4f2b3c1c4e0addc3e0348c6053ea60272e" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -3868,7 +3859,7 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli 0.31.1", + "gimli", "hashbrown 0.15.5", "log", "postcard", @@ -3880,40 +3871,42 @@ dependencies = [ "sha2", "smallvec", "target-lexicon 0.13.3", + "wasmtime-internal-math", ] [[package]] name = "cranelift-codegen-meta" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e461480d87f920c2787422463313326f67664e68108c14788ba1676f5edfcd15" +checksum = "96d6817fdc15cb8f236fc9d8e610767d3a03327ceca4abff7a14d8e2154c405e" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", "cranelift-srcgen", + "heck 0.5.0", "pulley-interpreter", ] [[package]] name = "cranelift-codegen-shared" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976584d09f200c6c84c4b9ff7af64fc9ad0cb64dffa5780991edd3fe143a30a1" +checksum = "0403796328e9e2e7df2b80191cdbb473fd9ea3889eb45ef5632d0fef168ea032" [[package]] name = "cranelift-control" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46d43d70f4e17c545aa88dbf4c84d4200755d27c6e3272ebe4de65802fa6a955" +checksum = "188f04092279a3814e0b6235c2f9c2e34028e4beb72da7bfed55cbd184702bcc" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75418674520cb400c8772bfd6e11a62736c78fc1b6e418195696841d1bf91f1" +checksum = "43f5e7391167605d505fe66a337e1a69583b3f34b63d359ffa5a430313c555e8" dependencies = [ "cranelift-bitset", "serde", @@ -3922,9 +3915,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8b1a91c86687a344f3c52dd6dfb6e50db0dfa7f2e9c7711b060b3623e1fdeb" +checksum = "ea5440792eb2b5ba0a0976df371b9f94031bd853ae56f389de610bca7128a7cb" dependencies = [ "cranelift-codegen", "log", @@ -3934,15 +3927,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711baa4e3432d4129295b39ec2b4040cc1b558874ba0a37d08e832e857db7285" +checksum = "1e5c05fab6fce38d729088f3fa1060eaa1ad54eefd473588887205ed2ab2f79e" [[package]] name = "cranelift-native" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41c83e8666e3bcc5ffeaf6f01f356f0e1f9dcd69ce5511a1efd7ca5722001a3f" +checksum = "9c9a0607a028edf5ba5bba7e7cf5ca1b7f0a030e3ae84dcd401e8b9b05192280" dependencies = [ "cranelift-codegen", "libc", @@ -3951,9 +3944,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.120.2" +version = "0.123.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e3f4d783a55c64266d17dc67d2708852235732a100fc40dd9f1051adc64d7b" +checksum = "cb0f2da72eb2472aaac6cfba4e785af42b1f2d82f5155f30c9c30e8cce351e17" [[package]] name = "crash-context" @@ -7054,21 +7047,15 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" dependencies = [ "fallible-iterator", "indexmap", "stable_deref_trait", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "gio-sys" version = "0.21.5" @@ -11361,9 +11348,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "crc32fast", "hashbrown 0.15.5", @@ -11371,15 +11358,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "ollama" version = "0.1.0" @@ -13518,13 +13496,25 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" [[package]] name = "pulley-interpreter" -version = "33.0.2" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986beaef947a51d17b42b0ea18ceaa88450d35b6994737065ed505c39172db71" +checksum = "499d922aa0f9faac8d92351416664f1b7acd914008a90fce2f0516d31efddf67" dependencies = [ "cranelift-bitset", "log", - "wasmtime-math", + "pulley-macros", + "wasmtime-internal-math", +] + +[[package]] +name = "pulley-macros" +version = "36.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3848fb193d6dffca43a21f24ca9492f22aab88af1223d06bac7f8a0ef405b81" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -16194,12 +16184,6 @@ dependencies = [ "der 0.7.10", ] -[[package]] -name = "sptr" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" - [[package]] name = "sqlez" version = "0.1.0" @@ -18242,17 +18226,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "trait-variant" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "transpose" version = "0.2.3" @@ -18265,9 +18238,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.26.3" +version = "0.26.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "974d205cc395652cfa8b37daa053fe56eebd429acf8dc055503fee648dae981e" +checksum = "887bd495d0582c5e3e0d8ece2233666169fa56a9644d172fc22ad179ab2d0538" dependencies = [ "cc", "regex", @@ -19360,12 +19333,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.229.0" +version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38ba1d491ecacb085a2552025c10a675a6fddcbd03b1fc9b36c536010ce265d2" +checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" dependencies = [ "leb128fmt", - "wasmparser 0.229.0", + "wasmparser 0.236.1", ] [[package]] @@ -19488,9 +19461,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.229.0" +version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3b1f053f5d41aa55640a1fa9b6d1b8a9e4418d118ce308d20e24ff3575a8c" +checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", @@ -19513,22 +19486,22 @@ dependencies = [ [[package]] name = "wasmprinter" -version = "0.229.0" +version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25dac01892684a99b8fbfaf670eb6b56edea8a096438c75392daeb83156ae2e" +checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.229.0", + "wasmparser 0.236.1", ] [[package]] name = "wasmtime" -version = "33.0.2" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57373e1d8699662fb791270ac5dfac9da5c14f618ecf940cdb29dc3ad9472a3c" +checksum = "6a2f8736ddc86e03a9d0e4c477a37939cfc53cd1b052ee38a3133679b87ef830" dependencies = [ - "addr2line 0.24.2", + "addr2line", "anyhow", "async-trait", "bitflags 2.10.0", @@ -19542,10 +19515,9 @@ dependencies = [ "log", "mach2 0.4.3", "memfd", - "object 0.36.7", + "object", "once_cell", "postcard", - "psm", "pulley-interpreter", "rayon", "rustix 1.1.2", @@ -19553,82 +19525,109 @@ dependencies = [ "serde", "serde_derive", "smallvec", - "sptr", "target-lexicon 0.13.3", - "trait-variant", - "wasmparser 0.229.0", - "wasmtime-asm-macros", - "wasmtime-component-macro", - "wasmtime-component-util", - "wasmtime-cranelift", + "wasmparser 0.236.1", "wasmtime-environ", - "wasmtime-fiber", - "wasmtime-jit-icache-coherence", - "wasmtime-math", - "wasmtime-slab", - "wasmtime-versioned-export-macros", - "wasmtime-winch", - "windows-sys 0.59.0", + "wasmtime-internal-asm-macros", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-math", + "wasmtime-internal-slab", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "windows-sys 0.60.2", ] [[package]] -name = "wasmtime-asm-macros" -version = "33.0.2" +name = "wasmtime-c-api-impl" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0fc91372865167a695dc98d0d6771799a388a7541d3f34e939d0539d6583de" +checksum = "f3c62ea3fa30e6b0cf61116b3035121b8f515c60ac118ebfdab2ee56d028ed1e" dependencies = [ - "cfg-if", + "anyhow", + "log", + "tracing", + "wasmtime", + "wasmtime-internal-c-api-macros", ] [[package]] -name = "wasmtime-c-api-impl" -version = "33.0.2" +name = "wasmtime-environ" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46db556f1dccdd88e0672bd407162ab0036b72e5eccb0f4398d8251cba32dba1" +checksum = "733682a327755c77153ac7455b1ba8f2db4d9946c1738f8002fe1fbda1d52e83" dependencies = [ "anyhow", + "cpp_demangle", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", "log", - "tracing", - "wasmtime", - "wasmtime-c-api-macros", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon 0.13.3", + "wasm-encoder 0.236.1", + "wasmparser 0.236.1", + "wasmprinter", + "wasmtime-internal-component-util", +] + +[[package]] +name = "wasmtime-internal-asm-macros" +version = "36.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68288980a2e02bcb368d436da32565897033ea21918007e3f2bae18843326cf9" +dependencies = [ + "cfg-if", ] [[package]] -name = "wasmtime-c-api-macros" -version = "33.0.2" +name = "wasmtime-internal-c-api-macros" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "315cc6bc8cdc66f296accb26d7625ae64c1c7b6da6f189e8a72ce6594bf7bd36" +checksum = "3c8c61294155a6d23c202f08cf7a2f9392a866edd50517508208818be626ce9f" dependencies = [ "proc-macro2", "quote", ] [[package]] -name = "wasmtime-component-macro" -version = "33.0.2" +name = "wasmtime-internal-component-macro" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25c9c7526675ff9a9794b115023c4af5128e3eb21389bfc3dc1fd344d549258f" +checksum = "5dea846da68f8e776c8a43bde3386022d7bb74e713b9654f7c0196e5ff2e4684" dependencies = [ "anyhow", "proc-macro2", "quote", "syn 2.0.117", - "wasmtime-component-util", - "wasmtime-wit-bindgen", - "wit-parser 0.229.0", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.236.1", ] [[package]] -name = "wasmtime-component-util" -version = "33.0.2" +name = "wasmtime-internal-component-util" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc42ec8b078875804908d797cb4950fec781d9add9684c9026487fd8eb3f6291" +checksum = "fe1e5735b3c8251510d2a55311562772d6c6fca9438a3d0329eb6e38af4957d6" [[package]] -name = "wasmtime-cranelift" -version = "33.0.2" +name = "wasmtime-internal-cranelift" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bd72f0a6a0ffcc6a184ec86ac35c174e48ea0e97bbae277c8f15f8bf77a566" +checksum = "e89bb9ef571288e2be6b8a3c4763acc56c348dcd517500b1679d3ffad9e4a757" dependencies = [ "anyhow", "cfg-if", @@ -19637,104 +19636,132 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli 0.31.1", + "gimli", "itertools 0.14.0", "log", - "object 0.36.7", + "object", "pulley-interpreter", "smallvec", "target-lexicon 0.13.3", "thiserror 2.0.17", - "wasmparser 0.229.0", + "wasmparser 0.236.1", "wasmtime-environ", - "wasmtime-versioned-export-macros", + "wasmtime-internal-math", + "wasmtime-internal-versioned-export-macros", ] [[package]] -name = "wasmtime-environ" -version = "33.0.2" +name = "wasmtime-internal-fiber" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6187bb108a23eb25d2a92aa65d6c89fb5ed53433a319038a2558567f3011ff2" +checksum = "b698d004b15ea1f1ae2d06e5e8b80080cbd684fd245220ce2fac3cdd5ecf87f2" dependencies = [ "anyhow", - "cpp_demangle", - "cranelift-bitset", - "cranelift-entity", - "gimli 0.31.1", - "indexmap", - "log", - "object 0.36.7", - "postcard", - "rustc-demangle", - "semver", - "serde", - "serde_derive", - "smallvec", - "target-lexicon 0.13.3", - "wasm-encoder 0.229.0", - "wasmparser 0.229.0", - "wasmprinter", - "wasmtime-component-util", + "cc", + "cfg-if", + "libc", + "rustix 1.1.2", + "wasmtime-internal-asm-macros", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.60.2", ] [[package]] -name = "wasmtime-fiber" -version = "33.0.2" +name = "wasmtime-internal-jit-debug" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc8965d2128c012329f390e24b8b2758dd93d01bf67e1a1a0dd3d8fd72f56873" +checksum = "c803a9fec05c3d7fa03474d4595079d546e77a3c71c1d09b21f74152e2165c17" dependencies = [ - "anyhow", "cc", - "cfg-if", - "rustix 1.1.2", - "wasmtime-asm-macros", - "wasmtime-versioned-export-macros", - "windows-sys 0.59.0", + "wasmtime-internal-versioned-export-macros", ] [[package]] -name = "wasmtime-jit-icache-coherence" -version = "33.0.2" +name = "wasmtime-internal-jit-icache-coherence" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7af0e940cb062a45c0b3f01a926f77da5947149e99beb4e3dd9846d5b8f11619" +checksum = "d3866909d37f7929d902e6011847748147e8734e9d7e0353e78fb8b98f586aee" dependencies = [ "anyhow", "cfg-if", "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] -name = "wasmtime-math" -version = "33.0.2" +name = "wasmtime-internal-math" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acfca360e719dda9a27e26944f2754ff2fd5bad88e21919c42c5a5f38ddd93cb" +checksum = "5a23b03fb14c64bd0dfcaa4653101f94ade76c34a3027ed2d6b373267536e45b" dependencies = [ "libm", ] [[package]] -name = "wasmtime-slab" -version = "33.0.2" +name = "wasmtime-internal-slab" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e240559cada55c4b24af979d5f6c95e0029f5772f32027ec3c62b258aaff65" +checksum = "fbff220b88cdb990d34a20b13344e5da2e7b99959a5b1666106bec94b58d6364" [[package]] -name = "wasmtime-versioned-export-macros" -version = "33.0.2" +name = "wasmtime-internal-unwinder" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0963c1438357a3d8c0efe152b4ef5259846c1cf8b864340270744fe5b3bae5e" +checksum = "13e1ad30e88988b20c0d1c56ea4b4fbc01a8c614653cbf12ca50c0dcc695e2f7" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "log", + "object", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "36.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549aefdaa1398c2fcfbf69a7b882956bb5b6e8e5b600844ecb91a3b5bf658ca7" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "wasmtime-internal-winch" +version = "36.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc96a84c5700171aeecf96fa9a9ab234f333f5afb295dabf3f8a812b70fe832" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object", + "target-lexicon 0.13.3", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "36.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28dc9efea511598c88564ac1974e0825c07d9c0de902dbf68f227431cd4ff8c" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "heck 0.5.0", + "indexmap", + "wit-parser 0.236.1", +] + [[package]] name = "wasmtime-wasi" -version = "33.0.2" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae951b72c7c6749a1c15dcdfb6d940a2614c932b4a54f474636e78e2c744b4c" +checksum = "c3c2e99fbaa0c26b4680e0c9af07e3f7b25f5fbc1ad97dd34067980bd027d3e5" dependencies = [ "anyhow", "async-trait", @@ -19758,14 +19785,14 @@ dependencies = [ "wasmtime", "wasmtime-wasi-io", "wiggle", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "wasmtime-wasi-io" -version = "33.0.2" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a835790dcecc3d7051ec67da52ba9e04af25e1bc204275b9391e3f0042b10797" +checksum = "de2dc367052562c228ce51ee4426330840433c29c0ea3349eca5ddeb475ecdb9" dependencies = [ "anyhow", "async-trait", @@ -19774,35 +19801,6 @@ dependencies = [ "wasmtime", ] -[[package]] -name = "wasmtime-winch" -version = "33.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc3b117d03d6eeabfa005a880c5c22c06503bb8820f3aa2e30f0e8d87b6752f" -dependencies = [ - "anyhow", - "cranelift-codegen", - "gimli 0.31.1", - "object 0.36.7", - "target-lexicon 0.13.3", - "wasmparser 0.229.0", - "wasmtime-cranelift", - "wasmtime-environ", - "winch-codegen", -] - -[[package]] -name = "wasmtime-wit-bindgen" -version = "33.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1382f4f09390eab0d75d4994d0c3b0f6279f86a571807ec67a8253c87cf6a145" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap", - "wit-parser 0.229.0", -] - [[package]] name = "wast" version = "35.0.2" @@ -20257,9 +20255,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "33.0.2" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "649c1aca13ef9e9dccf2d5efbbebf12025bc5521c3fb7754355ef60f5eb810be" +checksum = "c13d1ae265bd6e5e608827d2535665453cae5cb64950de66e2d5767d3e32c43a" dependencies = [ "anyhow", "async-trait", @@ -20272,9 +20270,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "33.0.2" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "164870fc34214ee42bd81b8ce9e7c179800fa1a7d4046d17a84e7f7bf422c8ad" +checksum = "607c4966f6b30da20d24560220137cbd09df722f0558eac81c05624700af5e05" dependencies = [ "anyhow", "heck 0.5.0", @@ -20286,9 +20284,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "33.0.2" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d873bb5b59ca703b5e41562e96a4796d1af61bf4cf80bf8a7abda755a380ec1c" +checksum = "fc36e39412fa35f7cc86b3705dbe154168721dd3e71f6dc4a726b266d5c60c55" dependencies = [ "proc-macro2", "quote", @@ -20329,21 +20327,22 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "33.0.2" +version = "36.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7914c296fbcef59d1b89a15e82384d34dc9669bc09763f2ef068a28dd3a64ebf" +checksum = "06c0ec09e8eb5e850e432da6271ed8c4a9d459a9db3850c38e98a3ee9d015e79" dependencies = [ "anyhow", "cranelift-assembler-x64", "cranelift-codegen", - "gimli 0.31.1", + "gimli", "regalloc2", "smallvec", "target-lexicon 0.13.3", "thiserror 2.0.17", - "wasmparser 0.229.0", - "wasmtime-cranelift", + "wasmparser 0.236.1", "wasmtime-environ", + "wasmtime-internal-cranelift", + "wasmtime-internal-math", ] [[package]] @@ -21369,9 +21368,9 @@ dependencies = [ [[package]] name = "wit-parser" -version = "0.229.0" +version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459c6ba62bf511d6b5f2a845a2a736822e38059c1cfa0b644b467bbbfae4efa6" +checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" dependencies = [ "anyhow", "id-arena", @@ -21382,7 +21381,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.229.0", + "wasmparser 0.236.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6c8f2a78a401cc2adebb712cd8ce739c696af878..d1271e0166677fb4069b5917f320e57c755263b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -732,7 +732,7 @@ toml_edit = { version = "0.22", default-features = false, features = [ "serde", ] } tower-http = "0.4.4" -tree-sitter = { version = "0.26", features = ["wasm"] } +tree-sitter = { version = "0.26.8", features = ["wasm"] } tree-sitter-bash = "0.25.1" tree-sitter-c = "0.24.1" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } @@ -767,7 +767,7 @@ uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } walkdir = "2.5" wasm-encoder = "0.221" wasmparser = "0.221" -wasmtime = { version = "33", default-features = false, features = [ +wasmtime = { version = "36", default-features = false, features = [ "async", "demangle", "runtime", @@ -776,7 +776,7 @@ wasmtime = { version = "33", default-features = false, features = [ "incremental-cache", "parallel-compilation", ] } -wasmtime-wasi = "33" +wasmtime-wasi = "36" wax = "0.7" which = "6.0.0" wasm-bindgen = "0.2.113" diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index 286639cdd67d716b1137290baf269670ecddebe7..87a2032e831fc942f6848428a901a9fe3f613fc8 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -42,7 +42,7 @@ use wasmtime::{ CacheStore, Engine, Store, component::{Component, ResourceTable}, }; -use wasmtime_wasi::p2::{self as wasi, IoView as _}; +use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; use wit::Extension; pub struct WasmHost { @@ -93,7 +93,7 @@ impl extension::Extension for WasmExtension { ) -> Result { self.call(|extension, store| { async move { - let resource = store.data_mut().table().push(worktree)?; + let resource = store.data_mut().table.push(worktree)?; let command = extension .call_language_server_command( store, @@ -119,7 +119,7 @@ impl extension::Extension for WasmExtension { ) -> Result> { self.call(|extension, store| { async move { - let resource = store.data_mut().table().push(worktree)?; + let resource = store.data_mut().table.push(worktree)?; let options = extension .call_language_server_initialization_options( store, @@ -143,7 +143,7 @@ impl extension::Extension for WasmExtension { ) -> Result> { self.call(|extension, store| { async move { - let resource = store.data_mut().table().push(worktree)?; + let resource = store.data_mut().table.push(worktree)?; let options = extension .call_language_server_workspace_configuration( store, @@ -166,7 +166,7 @@ impl extension::Extension for WasmExtension { ) -> Result> { self.call(|extension, store| { async move { - let resource = store.data_mut().table().push(worktree)?; + let resource = store.data_mut().table.push(worktree)?; extension .call_language_server_initialization_options_schema( store, @@ -187,7 +187,7 @@ impl extension::Extension for WasmExtension { ) -> Result> { self.call(|extension, store| { async move { - let resource = store.data_mut().table().push(worktree)?; + let resource = store.data_mut().table.push(worktree)?; extension .call_language_server_workspace_configuration_schema( store, @@ -209,7 +209,7 @@ impl extension::Extension for WasmExtension { ) -> Result> { self.call(|extension, store| { async move { - let resource = store.data_mut().table().push(worktree)?; + let resource = store.data_mut().table.push(worktree)?; let options = extension .call_language_server_additional_initialization_options( store, @@ -234,7 +234,7 @@ impl extension::Extension for WasmExtension { ) -> Result> { self.call(|extension, store| { async move { - let resource = store.data_mut().table().push(worktree)?; + let resource = store.data_mut().table.push(worktree)?; let options = extension .call_language_server_additional_workspace_configuration( store, @@ -331,7 +331,7 @@ impl extension::Extension for WasmExtension { self.call(|extension, store| { async move { let resource = if let Some(delegate) = delegate { - Some(store.data_mut().table().push(delegate)?) + Some(store.data_mut().table.push(delegate)?) } else { None }; @@ -355,7 +355,7 @@ impl extension::Extension for WasmExtension { ) -> Result { self.call(|extension, store| { async move { - let project_resource = store.data_mut().table().push(project)?; + let project_resource = store.data_mut().table.push(project)?; let command = extension .call_context_server_command(store, context_server_id.clone(), project_resource) .await? @@ -374,7 +374,7 @@ impl extension::Extension for WasmExtension { ) -> Result> { self.call(|extension, store| { async move { - let project_resource = store.data_mut().table().push(project)?; + let project_resource = store.data_mut().table.push(project)?; let Some(configuration) = extension .call_context_server_configuration( store, @@ -417,7 +417,7 @@ impl extension::Extension for WasmExtension { ) -> Result<()> { self.call(|extension, store| { async move { - let kv_store_resource = store.data_mut().table().push(kv_store)?; + let kv_store_resource = store.data_mut().table.push(kv_store)?; extension .call_index_docs( store, @@ -444,7 +444,7 @@ impl extension::Extension for WasmExtension { ) -> Result { self.call(|extension, store| { async move { - let resource = store.data_mut().table().push(worktree)?; + let resource = store.data_mut().table.push(worktree)?; let dap_binary = extension .call_get_dap_binary(store, dap_name, config, user_installed_path, resource) .await? @@ -532,7 +532,7 @@ impl extension::Extension for WasmExtension { pub struct WasmState { manifest: Arc, pub table: ResourceTable, - ctx: wasi::WasiCtx, + ctx: WasiCtx, pub host: Arc, pub(crate) capability_granter: CapabilityGranter, } @@ -726,7 +726,7 @@ impl WasmHost { }) } - async fn build_wasi_ctx(&self, manifest: &Arc) -> Result { + async fn build_wasi_ctx(&self, manifest: &Arc) -> Result { let extension_work_dir = self.work_dir.join(manifest.id.as_ref()); self.fs .create_dir(&extension_work_dir) @@ -739,7 +739,7 @@ impl WasmHost { #[cfg(target_os = "windows")] let path = path.replace('\\', "/"); - let mut ctx = wasi::WasiCtxBuilder::new(); + let mut ctx = WasiCtxBuilder::new(); ctx.inherit_stdio() .env("PWD", &path) .env("RUST_BACKTRACE", "full"); @@ -947,15 +947,16 @@ impl WasmState { } } -impl wasi::IoView for WasmState { - fn table(&mut self) -> &mut ResourceTable { - &mut self.table - } +impl wasmtime::component::HasData for WasmState { + type Data<'a> = &'a mut WasmState; } -impl wasi::WasiView for WasmState { - fn ctx(&mut self) -> &mut wasi::WasiCtx { - &mut self.ctx +impl WasiView for WasmState { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { + ctx: &mut self.ctx, + table: &mut self.table, + } } } diff --git a/crates/extension_host/src/wasm_host/wit.rs b/crates/extension_host/src/wasm_host/wit.rs index 9c4d3aa298c366ae91d0f8195ed090d74099c6d0..27847422f01680240119877e0864491dd7660d68 100644 --- a/crates/extension_host/src/wasm_host/wit.rs +++ b/crates/extension_host/src/wasm_host/wit.rs @@ -42,18 +42,14 @@ pub use since_v0_0_4::LanguageServerConfig; pub fn new_linker( executor: &BackgroundExecutor, - f: impl Fn(&mut Linker, fn(&mut WasmState) -> &mut WasmState) -> Result<()>, + f: impl FnOnce(&mut Linker) -> Result<()>, ) -> Linker { let mut linker = Linker::new(&wasm_engine(executor)); wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap(); - f(&mut linker, wasi_view).unwrap(); + f(&mut linker).unwrap(); linker } -fn wasi_view(state: &mut WasmState) -> &mut WasmState { - state -} - /// Returns whether the given Wasm API version is supported by the Wasm host. pub fn is_supported_wasm_api_version(release_channel: ReleaseChannel, version: Version) -> bool { wasm_api_version_range(release_channel).contains(&version) diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs index fa7539eec9f454c95782cd0249664693074abfba..c231b7e5d69157d523973455b2437a576392a00d 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs @@ -12,8 +12,12 @@ use wasmtime::component::{Linker, Resource}; pub const MIN_VERSION: Version = Version::new(0, 0, 1); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.0.1", with: { "worktree": ExtensionWorktree, @@ -26,7 +30,11 @@ pub type ExtensionWorktree = Arc; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for latest::DownloadedFileType { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs index 6d7db749f0cd021bfb084eba1bc20ce72780f3d8..41d652cec3087e8e5458a048689be4494de63356 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs @@ -10,8 +10,12 @@ use wasmtime::component::{Linker, Resource}; pub const MIN_VERSION: Version = Version::new(0, 0, 4); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.0.4", with: { "worktree": ExtensionWorktree, @@ -24,7 +28,11 @@ pub type ExtensionWorktree = Arc; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for latest::DownloadedFileType { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs index e5ff0322088470d47e903c4a83794b654bbba531..e1dfdf8248b41de2de5e9faff3d212d06f1349c4 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs @@ -10,8 +10,12 @@ use wasmtime::component::{Linker, Resource}; pub const MIN_VERSION: Version = Version::new(0, 0, 6); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.0.6", with: { "worktree": ExtensionWorktree, @@ -31,7 +35,11 @@ pub type ExtensionWorktree = Arc; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for latest::Command { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs index 0caaa86c2413f1b279319eeea4d8577d1ed4b5a5..4cd034d4d6af02971468ba8e57e1eebf9078353f 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs @@ -26,8 +26,12 @@ use super::{latest, since_v0_6_0}; pub const MIN_VERSION: Version = Version::new(0, 1, 0); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.1.0", with: { "worktree": ExtensionWorktree, @@ -52,7 +56,11 @@ pub type ExtensionHttpResponseStream = Arc &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for latest::Command { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs index 074cce73c22d547cd3198a672e6f8cdc5f750d49..691e6d2dd549b64c3783406af210b6b48f4a1dbc 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs @@ -11,8 +11,12 @@ use super::{latest, since_v0_6_0}; pub const MIN_VERSION: Version = Version::new(0, 2, 0); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.2.0", with: { "worktree": ExtensionWorktree, @@ -40,7 +44,11 @@ pub type ExtensionKeyValueStore = Arc; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for latest::Command { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs index 072ad42f2b9c2f5b3a8556b237f3907052665370..53aa65d5187663ea86fa465af76cf3aebc7844e4 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs @@ -11,8 +11,12 @@ use super::{latest, since_v0_6_0}; pub const MIN_VERSION: Version = Version::new(0, 3, 0); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.3.0", with: { "worktree": ExtensionWorktree, @@ -40,7 +44,11 @@ pub type ExtensionKeyValueStore = Arc; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for latest::CodeLabel { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs index 4f1d5c6a48c13ff09a5c81e2b43683fa50a7ccec..44b7d7ba1ad4e3235e8772a051bb906f87c64325 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs @@ -11,8 +11,12 @@ use super::{latest, since_v0_6_0}; pub const MIN_VERSION: Version = Version::new(0, 4, 0); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.4.0", with: { "worktree": ExtensionWorktree, @@ -40,7 +44,11 @@ pub type ExtensionKeyValueStore = Arc; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for latest::CodeLabel { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs index 84f73f567750081d406b20025f0b4598cfd0f9af..4dff0d90a94fe1128c6182592093b38cf43fe573 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs @@ -11,8 +11,12 @@ use super::{latest, since_v0_6_0}; pub const MIN_VERSION: Version = Version::new(0, 5, 0); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.5.0", with: { "worktree": ExtensionWorktree, @@ -41,7 +45,11 @@ pub type ExtensionKeyValueStore = Arc; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for latest::CodeLabel { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index 202bcd6ce959b27b3b7ecf8e15830cb1955ec104..bc5674b051772e464c0cbdb74e75f935959e05d8 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -12,8 +12,12 @@ pub const MIN_VERSION: Version = Version::new(0, 6, 0); pub const MAX_VERSION: Version = Version::new(0, 7, 0); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.6.0", with: { "worktree": ExtensionWorktree, @@ -43,7 +47,11 @@ pub type ExtensionKeyValueStore = Arc; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for latest::CodeLabel { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs index 324a572f40c98037816870c99151a4789793da1b..660ddd9688f7dc69f3ec3c52452122fd807257ad 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -40,8 +40,12 @@ pub const MIN_VERSION: Version = Version::new(0, 8, 0); pub const MAX_VERSION: Version = Version::new(0, 8, 0); wasmtime::component::bindgen!({ - async: true, - trappable_imports: true, + imports: { + default: async | trappable, + }, + exports: { + default: async, + }, path: "../extension_api/wit/since_v0.8.0", with: { "worktree": ExtensionWorktree, @@ -65,7 +69,11 @@ pub type ExtensionHttpResponseStream = Arc &'static Linker { static LINKER: OnceLock> = OnceLock::new(); - LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) + LINKER.get_or_init(|| { + super::new_linker(executor, |linker| { + Extension::add_to_linker::<_, WasmState>(linker, |s| s) + }) + }) } impl From for std::ops::Range { From 4087d9f2ca1d6fec171c6751dbf32adad019acc5 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 31 Mar 2026 20:10:05 -0600 Subject: [PATCH 07/22] Remove Claude upsell (#52831) Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - Removed the (broken) Claude ACP upsell dialogue --- crates/agent_ui/src/agent_panel.rs | 11 +- crates/agent_ui/src/ui.rs | 2 - .../src/ui/claude_agent_onboarding_modal.rs | 261 ------------------ crates/title_bar/src/onboarding_banner.rs | 26 +- crates/title_bar/src/title_bar.rs | 21 +- crates/zed_actions/src/lib.rs | 2 - 6 files changed, 23 insertions(+), 300 deletions(-) delete mode 100644 crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index e6ef267a95110e745534010bae32b1b1fd6c0f0c..a32f92942682fc0c5efbbcd35a9848c90b761184 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -21,8 +21,8 @@ use settings::{LanguageModelProviderSetting, LanguageModelSelection}; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use zed_actions::agent::{ - AddSelectionToThread, ConflictContent, OpenClaudeAgentOnboardingModal, ReauthenticateAgent, - ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff, + AddSelectionToThread, ConflictContent, ReauthenticateAgent, ResolveConflictedFilesWithAgent, + ResolveConflictsWithAgent, ReviewBranchDiff, }; use crate::{ @@ -40,7 +40,7 @@ use crate::{ }; use crate::{ DEFAULT_THREAD_TITLE, - ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault}, + ui::{AcpOnboardingModal, HoldForDefault}, }; use crate::{ExpandMessageEditor, ThreadHistoryView}; use crate::{ManageProfiles, ThreadHistoryViewEvent}; @@ -245,11 +245,6 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| { AcpOnboardingModal::toggle(workspace, window, cx) }) - .register_action( - |workspace, _: &OpenClaudeAgentOnboardingModal, window, cx| { - ClaudeCodeOnboardingModal::toggle(workspace, window, cx) - }, - ) .register_action(|_workspace, _: &ResetOnboarding, window, cx| { window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx); window.refresh(); diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index 16732951ce67d76ca8d65259e309c4b81df30c3b..d43b7e4b043bcd1b155699c5eea3ca695585b94b 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -1,6 +1,5 @@ mod acp_onboarding_modal; mod agent_notification; -mod claude_agent_onboarding_modal; mod end_trial_upsell; mod hold_for_default; mod mention_crease; @@ -9,7 +8,6 @@ mod undo_reject_toast; pub use acp_onboarding_modal::*; pub use agent_notification::*; -pub use claude_agent_onboarding_modal::*; pub use end_trial_upsell::*; pub use hold_for_default::*; pub use mention_crease::*; diff --git a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs deleted file mode 100644 index 5b7e58eb4fd79a5075446dad997c2642fedf32a6..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs +++ /dev/null @@ -1,261 +0,0 @@ -use agent_servers::CLAUDE_AGENT_ID; -use gpui::{ - ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, - linear_color_stop, linear_gradient, -}; -use ui::{TintColor, Vector, VectorName, prelude::*}; -use workspace::{ModalView, Workspace}; - -use crate::{Agent, agent_panel::AgentPanel}; - -macro_rules! claude_agent_onboarding_event { - ($name:expr) => { - telemetry::event!($name, source = "ACP Claude Code Onboarding"); - }; - ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { - telemetry::event!($name, source = "ACP Claude Code Onboarding", $($key $(= $value)?),+); - }; -} - -pub struct ClaudeCodeOnboardingModal { - focus_handle: FocusHandle, - workspace: Entity, -} - -impl ClaudeCodeOnboardingModal { - pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { - let workspace_entity = cx.entity(); - workspace.toggle_modal(window, cx, |_window, cx| Self { - workspace: workspace_entity, - focus_handle: cx.focus_handle(), - }); - } - - fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - self.workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(window, cx); - - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.new_agent_thread( - Agent::Custom { - id: CLAUDE_AGENT_ID.into(), - }, - window, - cx, - ); - }); - } - }); - - cx.emit(DismissEvent); - - claude_agent_onboarding_event!("Open Panel Clicked"); - } - - fn view_docs(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx); - cx.notify(); - - claude_agent_onboarding_event!("Documentation Link Clicked"); - } - - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } -} - -impl EventEmitter for ClaudeCodeOnboardingModal {} - -impl Focusable for ClaudeCodeOnboardingModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl ModalView for ClaudeCodeOnboardingModal {} - -impl Render for ClaudeCodeOnboardingModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let illustration_element = |icon: IconName, label: Option, opacity: f32| { - h_flex() - .px_1() - .py_0p5() - .gap_1() - .rounded_sm() - .bg(cx.theme().colors().element_active.opacity(0.05)) - .border_1() - .border_color(cx.theme().colors().border) - .border_dashed() - .child( - Icon::new(icon) - .size(IconSize::Small) - .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))), - ) - .map(|this| { - if let Some(label_text) = label { - this.child( - Label::new(label_text) - .size(LabelSize::Small) - .color(Color::Muted), - ) - } else { - this.child( - div().w_16().h_1().rounded_full().bg(cx - .theme() - .colors() - .element_active - .opacity(0.6)), - ) - } - }) - .opacity(opacity) - }; - - let illustration = h_flex() - .relative() - .h(rems_from_px(126.)) - .bg(cx.theme().colors().editor_background) - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .justify_center() - .gap_8() - .rounded_t_md() - .overflow_hidden() - .child( - div().absolute().inset_0().w(px(515.)).h(px(126.)).child( - Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.)) - .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))), - ), - ) - .child(div().absolute().inset_0().size_full().bg(linear_gradient( - 0., - linear_color_stop( - cx.theme().colors().elevated_surface_background.opacity(0.1), - 0.9, - ), - linear_color_stop( - cx.theme().colors().elevated_surface_background.opacity(0.), - 0., - ), - ))) - .child( - div() - .absolute() - .inset_0() - .size_full() - .bg(gpui::black().opacity(0.15)), - ) - .child( - Vector::new( - VectorName::AcpLogoSerif, - rems_from_px(257.), - rems_from_px(47.), - ) - .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))), - ) - .child( - v_flex() - .gap_1p5() - .child(illustration_element(IconName::Stop, None, 0.15)) - .child(illustration_element( - IconName::AiGemini, - Some("New Gemini CLI Thread".into()), - 0.3, - )) - .child( - h_flex() - .pl_1() - .pr_2() - .py_0p5() - .gap_1() - .rounded_sm() - .bg(cx.theme().colors().element_active.opacity(0.2)) - .border_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::AiClaude) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child(Label::new("New Claude Agent Thread").size(LabelSize::Small)), - ) - .child(illustration_element( - IconName::Stop, - Some("Your Agent Here".into()), - 0.3, - )) - .child(illustration_element(IconName::Stop, None, 0.15)), - ); - - let heading = v_flex() - .w_full() - .gap_1() - .child( - Label::new("Beta Release") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(Headline::new("Claude Agent: Natively in Zed").size(HeadlineSize::Large)); - - let copy = "Powered by the Agent Client Protocol, you can now run Claude Agent as\na first-class citizen in Zed's agent panel."; - - let open_panel_button = Button::new("open-panel", "Start with Claude Agent") - .style(ButtonStyle::Tinted(TintColor::Accent)) - .full_width() - .on_click(cx.listener(Self::open_panel)); - - let docs_button = Button::new("add-other-agents", "Add Other Agents") - .end_icon( - Icon::new(IconName::ArrowUpRight) - .size(IconSize::Indicator) - .color(Color::Muted), - ) - .full_width() - .on_click(cx.listener(Self::view_docs)); - - let close_button = h_flex().absolute().top_2().right_2().child( - IconButton::new("cancel", IconName::Close).on_click(cx.listener( - |_, _: &ClickEvent, _window, cx| { - claude_agent_onboarding_event!("Canceled", trigger = "X click"); - cx.emit(DismissEvent); - }, - )), - ); - - v_flex() - .id("acp-onboarding") - .key_context("AcpOnboardingModal") - .relative() - .w(rems(34.)) - .h_full() - .elevation_3(cx) - .track_focus(&self.focus_handle(cx)) - .overflow_hidden() - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| { - claude_agent_onboarding_event!("Canceled", trigger = "Action"); - cx.emit(DismissEvent); - })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { - this.focus_handle.focus(window, cx); - })) - .child(illustration) - .child( - v_flex() - .p_4() - .gap_2() - .child(heading) - .child(Label::new(copy).color(Color::Muted)) - .child( - v_flex() - .w_full() - .mt_2() - .gap_1() - .child(open_panel_button) - .child(docs_button), - ), - ) - .child(close_button) - } -} diff --git a/crates/title_bar/src/onboarding_banner.rs b/crates/title_bar/src/onboarding_banner.rs index f96ce3a92740da4a0aac3dc154384f20f3b05eb0..96400a91a0a26fdc6a4c1acb6387f27c3077e393 100644 --- a/crates/title_bar/src/onboarding_banner.rs +++ b/crates/title_bar/src/onboarding_banner.rs @@ -1,3 +1,7 @@ +// This module provides infrastructure for showing onboarding banners in the title bar. +// It's currently not in use but is kept for future feature announcements. +#![allow(dead_code)] + use gpui::{Action, Entity, Global, Render, SharedString}; use ui::{ButtonLike, Tooltip, prelude::*}; use util::ResultExt; @@ -94,21 +98,21 @@ fn persist_dismissed(source: &str, cx: &mut App) { } pub fn restore_banner(cx: &mut App) { - cx.defer(|cx| { - cx.global::() - .entity - .clone() - .update(cx, |this, cx| { + if let Some(banner_global) = cx.try_global::() { + let entity = banner_global.entity.clone(); + cx.defer(move |cx| { + entity.update(cx, |this, cx| { this.dismissed = false; cx.notify(); }); - }); + }); - let source = &cx.global::().entity.read(cx).source; - let dismissed_at = dismissed_at_key(source); - let kvp = db::kvp::KeyValueStore::global(cx); - cx.spawn(async move |_| kvp.delete_kvp(dismissed_at).await) - .detach_and_log_err(cx); + let source = &cx.global::().entity.read(cx).source; + let dismissed_at = dismissed_at_key(source); + let kvp = db::kvp::KeyValueStore::global(cx); + cx.spawn(async move |_| kvp.delete_kvp(dismissed_at).await) + .detach_and_log_err(cx); + } } impl Render for OnboardingBanner { diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 42c348bacb680e2a09586d0dc0279fc8c95d1604..440249907adb6d29602ad8e950d0fd26a2d1c31d 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -155,7 +155,7 @@ pub struct TitleBar { multi_workspace: Option>, application_menu: Option>, _subscriptions: Vec, - banner: Entity, + banner: Option>, update_version: Entity, screen_share_popover_handle: PopoverMenuHandle, _diagnostics_subscription: Option, @@ -246,7 +246,9 @@ impl Render for TitleBar { children.push(self.render_collaborator_list(window, cx).into_any_element()); if title_bar_settings.show_onboarding_banner { - children.push(self.banner.clone().into_any_element()) + if let Some(banner) = &self.banner { + children.push(banner.clone().into_any_element()) + } } let status = self.client.status(); @@ -385,19 +387,6 @@ impl TitleBar { })); } - let banner = cx.new(|cx| { - OnboardingBanner::new( - "ACP Claude Code Onboarding", - IconName::AiClaude, - "Claude Agent", - Some("Introducing:".into()), - zed_actions::agent::OpenClaudeAgentOnboardingModal.boxed_clone(), - cx, - ) - // When updating this to a non-AI feature release, remove this line. - .visible_when(|cx| !project::DisableAiSettings::get_global(cx).disable_ai) - }); - let update_version = cx.new(|cx| UpdateVersion::new(cx)); let platform_titlebar = cx.new(|cx| { let mut titlebar = PlatformTitleBar::new(id, cx); @@ -416,7 +405,7 @@ impl TitleBar { user_store, client, _subscriptions: subscriptions, - banner, + banner: None, update_version, screen_share_popover_handle: PopoverMenuHandle::default(), _diagnostics_subscription: None, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 75b21c528a1e6952700264a154ab4c15045149b0..66ccf9c41c1e1cfcb821e03b4e9b7d4803f53c0b 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -450,8 +450,6 @@ pub mod agent { OpenOnboardingModal, /// Opens the ACP onboarding modal. OpenAcpOnboardingModal, - /// Opens the Claude Agent onboarding modal. - OpenClaudeAgentOnboardingModal, /// Resets the agent onboarding state. ResetOnboarding, /// Starts a chat conversation with the agent. From 971775e3b266950fbee99d97b863afe769374321 Mon Sep 17 00:00:00 2001 From: Ian Chamberlain Date: Tue, 31 Mar 2026 19:50:01 -0700 Subject: [PATCH 08/22] gpui: Implement audible system bell (#47531) Relates to #5303 and https://github.com/zed-industries/zed/issues/40826#issuecomment-3684556858 although I haven't found anywhere an actual request for `gpui` itself to support a system alert sound. ### What Basically, this PR adds a function that triggers an OS-dependent alert sound, commonly used by terminal applications for `\a` / `BEL`, and GUI applications to indicate an action failed in some small way (e.g. no search results found, unable to move cursor, button disabled). Also updated the `input` example, which now plays the bell if the user presses backspace with nothing behind the cursor to delete, or delete with nothing in front of the cursor. Test with `cargo run --example input --features gpui_platform/font-kit`. ### Why If this is merged, I plan to take a second step: - Add a new Zed setting (probably something like `terminal.audible_bell`) - If enabled, `printf '\a'`, `tput bel` etc. would call this new API to play an audible sound This isn't the super-shiny dream of #5303 but it would allow users to more easily configure tasks to notify when done. Plus, any TUI/CLI apps that expect this functionality will work. Also, I think many terminal users expect something like this (WezTerm, iTerm, etc. almost all support this). ### Notes ~I was only able to test on macOS and Windows, so if there are any Linux users who could verify this works for X11 / Wayland that would be a huge help! If not I can try~ Confirmed Wayland + X11 both working when I ran the example on a NixOS desktop Release Notes: - N/A --- Cargo.lock | 11 +++++++++++ Cargo.toml | 2 ++ crates/gpui/examples/input.rs | 14 ++++++++++++-- crates/gpui/src/platform.rs | 2 ++ crates/gpui/src/window.rs | 6 ++++++ crates/gpui_linux/src/linux/wayland/client.rs | 4 ++++ crates/gpui_linux/src/linux/wayland/window.rs | 12 ++++++++++++ crates/gpui_linux/src/linux/x11/window.rs | 5 +++++ crates/gpui_macos/Cargo.toml | 1 + crates/gpui_macos/src/window.rs | 5 +++++ crates/gpui_windows/src/window.rs | 9 ++++++++- 11 files changed, 68 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f68704cb6ef68887b102f7f6a1a37c0fe694f662..bfd80726843695dbfcb4baf1db4fe3e6ca9a4682 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7601,6 +7601,7 @@ dependencies = [ "media", "metal", "objc", + "objc2-app-kit", "parking_lot", "pathfinder_geometry", "raw-window-handle", @@ -11211,6 +11212,16 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-audio-toolbox" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index d1271e0166677fb4069b5917f320e57c755263b4..3a393237ab9f5a5a8cd4b02517f6d22382ff51ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -604,6 +604,7 @@ nbformat = "1.2.0" nix = "0.29" num-format = "0.4.4" objc = "0.2" +objc2-app-kit = { version = "0.3", default-features = false, features = [ "NSGraphics" ] } objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "NSArray", "NSAttributedString", @@ -821,6 +822,7 @@ features = [ "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Console", + "Win32_System_Diagnostics_Debug", "Win32_System_DataExchange", "Win32_System_IO", "Win32_System_LibraryLoader", diff --git a/crates/gpui/examples/input.rs b/crates/gpui/examples/input.rs index d15d791cd008883506389cc7bb16dbad765969c0..370e27de7d54c317af6683c240f343e750c68698 100644 --- a/crates/gpui/examples/input.rs +++ b/crates/gpui/examples/input.rs @@ -85,14 +85,24 @@ impl TextInput { fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { - self.select_to(self.previous_boundary(self.cursor_offset()), cx) + let prev = self.previous_boundary(self.cursor_offset()); + if self.cursor_offset() == prev { + window.play_system_bell(); + return; + } + self.select_to(prev, cx) } self.replace_text_in_range(None, "", window, cx) } fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { - self.select_to(self.next_boundary(self.cursor_offset()), cx) + let next = self.next_boundary(self.cursor_offset()); + if self.cursor_offset() == next { + window.play_system_bell(); + return; + } + self.select_to(next, cx) } self.replace_text_in_range(None, "", window, cx) } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 806a34040a4ec685c3d5c6ec01f47b5026e349a6..efca26a6b4802037a96490bf81f7d1c5c1d8b298 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -689,6 +689,8 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn update_ime_position(&self, _bounds: Bounds); + fn play_system_bell(&self) {} + #[cfg(any(test, feature = "test-support"))] fn as_test(&mut self) -> Option<&mut TestWindow> { None diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 48c381e5275e950bd6754541fedbab03ae3d64c2..7790480e32149fa33dfd082df7a8cdbb09568134 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5024,6 +5024,12 @@ impl Window { .set_tabbing_identifier(tabbing_identifier) } + /// Request the OS to play an alert sound. On some platforms this is associated + /// with the window, for others it's just a simple global function call. + pub fn play_system_bell(&self) { + self.platform_window.play_system_bell() + } + /// Toggles the inspector mode on this window. #[cfg(any(feature = "inspector", debug_assertions))] pub fn toggle_inspector(&mut self, cx: &mut App) { diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index b65a203dd3448ba191b7e2f5ae0f5b6c396545a8..10f4aab0db19978302143519dd6e2a7e4d25ec4d 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -58,6 +58,7 @@ use wayland_protocols::xdg::decoration::zv1::client::{ zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, }; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; +use wayland_protocols::xdg::system_bell::v1::client::xdg_system_bell_v1; use wayland_protocols::{ wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1}, xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1}, @@ -129,6 +130,7 @@ pub struct Globals { pub text_input_manager: Option, pub gesture_manager: Option, pub dialog: Option, + pub system_bell: Option, pub executor: ForegroundExecutor, } @@ -170,6 +172,7 @@ impl Globals { text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), gesture_manager: globals.bind(&qh, 1..=3, ()).ok(), dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), + system_bell: globals.bind(&qh, 1..=1, ()).ok(), executor, qh, } @@ -1069,6 +1072,7 @@ impl Dispatch for WaylandClientStat } delegate_noop!(WaylandClientStatePtr: ignore xdg_activation_v1::XdgActivationV1); +delegate_noop!(WaylandClientStatePtr: ignore xdg_system_bell_v1::XdgSystemBellV1); delegate_noop!(WaylandClientStatePtr: ignore wl_compositor::WlCompositor); delegate_noop!(WaylandClientStatePtr: ignore wp_cursor_shape_device_v1::WpCursorShapeDeviceV1); delegate_noop!(WaylandClientStatePtr: ignore wp_cursor_shape_manager_v1::WpCursorShapeManagerV1); diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index c4ff55fc80cc4d14069dd510b8e6855c17096773..1e3af66c59858c435ca3da093a1c48056b77667e 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1479,6 +1479,18 @@ impl PlatformWindow for WaylandWindow { fn gpu_specs(&self) -> Option { self.borrow().renderer.gpu_specs().into() } + + fn play_system_bell(&self) { + let state = self.borrow(); + let surface = if state.surface_state.toplevel().is_some() { + Some(&state.surface) + } else { + None + }; + if let Some(bell) = state.globals.system_bell.as_ref() { + bell.ring(surface); + } + } } fn update_window(mut state: RefMut) { diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 5e1287976cbb3ba9bc2c1571fa9e215f47fdd615..1974cc0bb28f62da4d7dcb3e9fca92b6324470bb 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1846,4 +1846,9 @@ impl PlatformWindow for X11Window { fn gpu_specs(&self) -> Option { self.0.state.borrow().renderer.gpu_specs().into() } + + fn play_system_bell(&self) { + // Volume 0% means don't increase or decrease from system volume + let _ = self.0.xcb.bell(0); + } } diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 06e5d0e7321af523a249f19ec0d5ac50e2da5d3f..3626bbd05e8a7c7fa2ae577f11e5277da995d2f7 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -48,6 +48,7 @@ mach2.workspace = true media.workspace = true metal.workspace = true objc.workspace = true +objc2-app-kit.workspace = true parking_lot.workspace = true pathfinder_geometry = "0.5" raw-window-handle = "0.6" diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 398cf46eab09dc8412ffdda8eb550b8ad4e09b40..ace36d695401ce76949129197dcd05135508f7d3 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -49,6 +49,7 @@ use objc::{ runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES}, sel, sel_impl, }; +use objc2_app_kit::NSBeep; use parking_lot::Mutex; use raw_window_handle as rwh; use smallvec::SmallVec; @@ -1676,6 +1677,10 @@ impl PlatformWindow for MacWindow { } } + fn play_system_bell(&self) { + unsafe { NSBeep() } + } + #[cfg(any(test, feature = "test-support"))] fn render_to_image(&self, scene: &gpui::Scene) -> Result { let mut this = self.0.lock(); diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 3a55100dfb75e961f57b977297bfcd2dc2ae2701..92255f93fd95969931c6b1ae8cb465ff628f82cb 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -20,7 +20,9 @@ use windows::{ Foundation::*, Graphics::Dwm::*, Graphics::Gdi::*, - System::{Com::*, LibraryLoader::*, Ole::*, SystemServices::*}, + System::{ + Com::*, Diagnostics::Debug::MessageBeep, LibraryLoader::*, Ole::*, SystemServices::*, + }, UI::{Controls::*, HiDpi::*, Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*}, }, core::*, @@ -950,6 +952,11 @@ impl PlatformWindow for WindowsWindow { self.0.update_ime_position(self.0.hwnd, caret_position); } + + fn play_system_bell(&self) { + // MB_OK: The sound specified as the Windows Default Beep sound. + let _ = unsafe { MessageBeep(MB_OK) }; + } } #[implement(IDropTarget)] From 878aba817ce048bfb5ba83363cd984cdeae57980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Soares?= <37777652+Dnreikronos@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:22:56 -0300 Subject: [PATCH 09/22] =?UTF-8?q?markdown:=20Show=20copy=20button=20on=20h?= =?UTF-8?q?over=20to=20prevent=20overlapping=20code=20block=E2=80=A6=20(#5?= =?UTF-8?q?2837)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [X] Performance impact has been considered and is acceptable Closes #52064 Release Notes: - Fixed copy button overlapping code block content in the Agent panel (#52064) ## Demo Before: image image After: https://github.com/user-attachments/assets/a139db06-3909-4a22-881a-836262ed3c36 --- crates/markdown/src/markdown.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 024e377c2538214c9579c8f025250e2166cf7ace..6063e98229025d4160b9d3aeb4b412494f443e7d 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -826,8 +826,8 @@ impl MarkdownElement { markdown, style, code_block_renderer: CodeBlockRenderer::Default { - copy_button: true, - copy_button_on_hover: false, + copy_button: false, + copy_button_on_hover: true, border: false, }, on_url_click: None, From 0b275eaa44c8bc789c8b3535826d950b61b13dbe Mon Sep 17 00:00:00 2001 From: Vivek Jain Date: Tue, 31 Mar 2026 20:51:59 -0700 Subject: [PATCH 10/22] Change behavior of search with vim mode enabled (#51073) When vim mode is enabled, previously if Cmd-F (or platform equivalent) was pressed, enter will go to the editor's first match, and then hitting enter again goes to the next line rather than next match. This PR changes it to make enter go to the next match, which matches the convention in most other programs. The behavior when search is initiated with / is left unchanged. This is a reopen of #35157, rebased and fixed. Closes #7692 Release Notes: - In vim mode, when search is triggered by the non-vim mode shortcut (cmd-f by default) enter will now behave as it does outside of vim mode. --------- Co-authored-by: Conrad Irwin --- crates/vim/src/helix.rs | 1 + crates/vim/src/normal/search.rs | 41 +++++++++++++++++++++++++++++++++ crates/vim/src/state.rs | 1 + crates/vim/src/vim.rs | 10 +++++--- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index c1e766c03a897facb3c7acf76b3ef7811e6910a8..d2c8f4b78dcde8c4f2135b63ee3d07f04e01ebd5 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -648,6 +648,7 @@ impl Vim { self.search = SearchState { direction: searchable::Direction::Next, count: 1, + cmd_f_search: false, prior_selections, prior_operator: self.operator_stack.last().cloned(), prior_mode: self.mode, diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 248f43c08192182cb266dbfc43a5a769f87429cd..6a8394f44710b7e241b7ba38f4913899a5afbce6 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -284,6 +284,7 @@ impl Vim { self.search = SearchState { direction, count, + cmd_f_search: false, prior_selections, prior_operator: self.operator_stack.last().cloned(), prior_mode, @@ -298,6 +299,7 @@ impl Vim { let current_mode = self.mode; self.search = Default::default(); self.search.prior_mode = current_mode; + self.search.cmd_f_search = true; cx.propagate(); } @@ -957,6 +959,45 @@ mod test { cx.assert_editor_state("«oneˇ» one one one"); } + #[gpui::test] + async fn test_non_vim_search_in_vim_mode(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.cx.set_state("ˇone one one one"); + cx.run_until_parked(); + cx.simulate_keystrokes("cmd-f"); + cx.run_until_parked(); + + cx.assert_state("«oneˇ» one one one", Mode::Visual); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + cx.assert_state("one «oneˇ» one one", Mode::Visual); + cx.simulate_keystrokes("shift-enter"); + cx.run_until_parked(); + cx.assert_state("«oneˇ» one one one", Mode::Visual); + + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + cx.assert_state("«oneˇ» one one one", Mode::Visual); + } + + #[gpui::test] + async fn test_non_vim_search_in_vim_insert_mode(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state("ˇone one one one", Mode::Insert); + cx.run_until_parked(); + cx.simulate_keystrokes("cmd-f"); + cx.run_until_parked(); + + cx.assert_state("«oneˇ» one one one", Mode::Insert); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + cx.assert_state("one «oneˇ» one one", Mode::Insert); + + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + cx.assert_state("one «oneˇ» one one", Mode::Insert); + } + #[gpui::test] async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 2ae4abe33a0fbb4bc6f8a838e60dc0857949e0dc..2fa5382c542999b8d3cb53ea85bed4c99257a3ea 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1022,6 +1022,7 @@ impl Clone for ReplayableAction { pub struct SearchState { pub direction: Direction, pub count: usize, + pub cmd_f_search: bool, pub prior_selections: Vec>, pub prior_operator: Option, diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 05046899b6164f7c5884e3ad64ad69caaeb2015f..6e1849340f17b776a34546dd9a118dc55e8dab84 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -432,8 +432,12 @@ pub fn init(cx: &mut App) { .and_then(|item| item.act_as::(cx)) .and_then(|editor| editor.read(cx).addon::().cloned()); let Some(vim) = vim else { return }; - vim.entity.update(cx, |_, cx| { - cx.defer_in(window, |vim, window, cx| vim.search_submit(window, cx)) + vim.entity.update(cx, |vim, cx| { + if !vim.search.cmd_f_search { + cx.defer_in(window, |vim, window, cx| vim.search_submit(window, cx)) + } else { + cx.propagate() + } }) }); workspace.register_action(|_, _: &GoToTab, window, cx| { @@ -2086,7 +2090,7 @@ impl Vim { VimEditorSettingsState { cursor_shape: self.cursor_shape(cx), clip_at_line_ends: self.clip_at_line_ends(), - collapse_matches: !HelixModeSetting::get_global(cx).0, + collapse_matches: !HelixModeSetting::get_global(cx).0 && !self.search.cmd_f_search, input_enabled: self.editor_input_enabled(), expects_character_input: self.expects_character_input(), autoindent: self.should_autoindent(), From a3964a565cedee303bccbdd8471255fa4f04d2c4 Mon Sep 17 00:00:00 2001 From: Oleksandr Kholiavko <43780952+HalavicH@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:13:24 +0200 Subject: [PATCH 11/22] Rework column/table width API in data table (#51060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data_table: Replace column width builder API with `ColumnWidthConfig` enum This PR consolidates the data table width configuration API from three separate builder methods (`.column_widths()`, `.resizable_columns()`, `.width()`) into a single `.width_config(ColumnWidthConfig)` call. This makes invalid state combinations unrepresentable and clarifies the two distinct width management modes. **What changed:** - Introduces `ColumnWidthConfig` enum with two variants: - `Static`: Fixed column widths, no resize handles - `Redistributable`: Drag-to-resize columns that redistribute space within a fixed table width - Introduces `TableResizeBehavior` enum (`None`, `Resizable`, `MinSize(f32)`) for per-column resize policy - Renames `TableColumnWidths` → `RedistributableColumnsState` to better reflect its purpose - Extracts all width management logic into a new `width_management.rs` module - Updates all callers: `csv_preview`, `git_graph`, `keymap_editor`, `edit_prediction_context_view` ```rust pub enum ColumnWidthConfig { /// Static column widths (no resize handles). Static { widths: StaticColumnWidths, /// Controls widths of the whole table. table_width: Option, }, /// Redistributable columns — dragging redistributes the fixed available space /// among columns without changing the overall table width. Redistributable { entity: Entity, table_width: Option, }, } ``` **Why:** The old API allowed callers to combine methods incorrectly. The new enum-based design enforces correct usage at compile time and provides a clearer path for adding independently resizable columns in PR #3. **Context:** This is part 2 of a 3-PR series improving data table column width handling: 1. [#51059](https://github.com/zed-industries/zed/pull/51059) - Extract modules into separate files (mechanical change) 2. **This PR**: Introduce width config enum for redistributable column widths (API rework) 3. Implement independently resizable column widths (new feature) The series builds on previously merged infrastructure: - [#46341](https://github.com/zed-industries/zed/pull/46341) - Data table dynamic column support - [#46190](https://github.com/zed-industries/zed/pull/46190) - Variable row height mode for data tables Primary beneficiary: CSV preview feature ([#48207](https://github.com/zed-industries/zed/pull/48207)) ### Anthony's note This PR also fixes the table dividers being a couple pixels off, and the csv preview from having double line rendering for a single column in some cases. 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 - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --------- Co-authored-by: Anthony Eid --- crates/csv_preview/src/csv_preview.rs | 46 +- crates/csv_preview/src/parser.rs | 5 +- .../csv_preview/src/renderer/render_table.rs | 43 +- .../src/renderer/row_identifiers.rs | 1 + crates/csv_preview/src/renderer/table_cell.rs | 1 - crates/git_graph/src/git_graph.rs | 58 +- crates/keymap_editor/src/keymap_editor.rs | 55 +- crates/ui/src/components/data_table.rs | 672 +++++++++--------- crates/ui/src/components/data_table/tests.rs | 4 +- 9 files changed, 462 insertions(+), 423 deletions(-) diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index b0b6ad4186758fd33693d5ee29bd2f0d4d28b816..c38cefb2456b3f44e3cac61b02294ab1ed1e79f4 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -9,7 +9,10 @@ use std::{ }; use crate::table_data_engine::TableDataEngine; -use ui::{SharedString, TableColumnWidths, TableInteractionState, prelude::*}; +use ui::{ + AbsoluteLength, DefiniteLength, RedistributableColumnsState, SharedString, + TableInteractionState, TableResizeBehavior, prelude::*, +}; use workspace::{Item, SplitDirection, Workspace}; use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent}; @@ -52,6 +55,32 @@ pub fn init(cx: &mut App) { } impl CsvPreviewView { + pub(crate) fn sync_column_widths(&self, cx: &mut Context) { + // plus 1 for the rows column + let cols = self.engine.contents.headers.cols() + 1; + let remaining_col_number = cols.saturating_sub(1); + let fraction = if remaining_col_number > 0 { + 1. / remaining_col_number as f32 + } else { + 1. + }; + let mut widths = vec![DefiniteLength::Fraction(fraction); cols]; + let line_number_width = self.calculate_row_identifier_column_width(); + widths[0] = DefiniteLength::Absolute(AbsoluteLength::Pixels(line_number_width.into())); + + let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols]; + resize_behaviors[0] = TableResizeBehavior::None; + + self.column_widths.widths.update(cx, |state, _cx| { + if state.cols() != cols + || state.initial_widths().as_slice() != widths.as_slice() + || state.resize_behavior().as_slice() != resize_behaviors.as_slice() + { + *state = RedistributableColumnsState::new(cols, widths, resize_behaviors); + } + }); + } + pub fn register(workspace: &mut Workspace) { workspace.register_action_renderer(|div, _, _, cx| { div.when(cx.has_flag::(), |div| { @@ -286,18 +315,19 @@ impl PerformanceMetrics { /// Holds state of column widths for a table component in CSV preview. pub(crate) struct ColumnWidths { - pub widths: Entity, + pub widths: Entity, } impl ColumnWidths { pub(crate) fn new(cx: &mut Context, cols: usize) -> Self { Self { - widths: cx.new(|cx| TableColumnWidths::new(cols, cx)), + widths: cx.new(|_cx| { + RedistributableColumnsState::new( + cols, + vec![ui::DefiniteLength::Fraction(1.0 / cols as f32); cols], + vec![ui::TableResizeBehavior::Resizable; cols], + ) + }), } } - /// Replace the current `TableColumnWidths` entity with a new one for the given column count. - pub(crate) fn replace(&self, cx: &mut Context, cols: usize) { - self.widths - .update(cx, |entity, cx| *entity = TableColumnWidths::new(cols, cx)); - } } diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs index b087404e0ebbd13cdaf20cab692f5470ea6ce292..efa3573d7aa53d97e2801ff00feb4665072830f4 100644 --- a/crates/csv_preview/src/parser.rs +++ b/crates/csv_preview/src/parser.rs @@ -80,11 +80,8 @@ impl CsvPreviewView { .insert("Parsing", (parse_duration, Instant::now())); log::debug!("Parsed {} rows", parsed_csv.rows.len()); - // Update table width so it can be rendered properly - let cols = parsed_csv.headers.cols(); - view.column_widths.replace(cx, cols + 1); // Add 1 for the line number column - view.engine.contents = parsed_csv; + view.sync_column_widths(cx); view.last_parse_end_time = Some(parse_end_time); view.apply_filter_sort(); diff --git a/crates/csv_preview/src/renderer/render_table.rs b/crates/csv_preview/src/renderer/render_table.rs index 0cc3bc3c46fb24570b3c99c9121dff3860c6b820..fb3d7e5fc603ba5b109319cfb19466dc3ad7652f 100644 --- a/crates/csv_preview/src/renderer/render_table.rs +++ b/crates/csv_preview/src/renderer/render_table.rs @@ -1,11 +1,9 @@ use crate::types::TableCell; use gpui::{AnyElement, Entity}; use std::ops::Range; -use ui::Table; -use ui::TableColumnWidths; -use ui::TableResizeBehavior; -use ui::UncheckedTableRow; -use ui::{DefiniteLength, div, prelude::*}; +use ui::{ + ColumnWidthConfig, RedistributableColumnsState, Table, UncheckedTableRow, div, prelude::*, +}; use crate::{ CsvPreviewView, @@ -15,44 +13,22 @@ use crate::{ impl CsvPreviewView { /// Creates a new table. - /// Column number is derived from the `TableColumnWidths` entity. + /// Column number is derived from the `RedistributableColumnsState` entity. pub(crate) fn create_table( &self, - current_widths: &Entity, + current_widths: &Entity, cx: &mut Context, ) -> AnyElement { - let cols = current_widths.read(cx).cols(); - let remaining_col_number = cols - 1; - let fraction = if remaining_col_number > 0 { - 1. / remaining_col_number as f32 - } else { - 1. // only column with line numbers is present. Put 100%, but it will be overwritten anyways :D - }; - let mut widths = vec![DefiniteLength::Fraction(fraction); cols]; - let line_number_width = self.calculate_row_identifier_column_width(); - widths[0] = DefiniteLength::Absolute(AbsoluteLength::Pixels(line_number_width.into())); - - let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols]; - resize_behaviors[0] = TableResizeBehavior::None; - - self.create_table_inner( - self.engine.contents.rows.len(), - widths, - resize_behaviors, - current_widths, - cx, - ) + self.create_table_inner(self.engine.contents.rows.len(), current_widths, cx) } fn create_table_inner( &self, row_count: usize, - widths: UncheckedTableRow, - resize_behaviors: UncheckedTableRow, - current_widths: &Entity, + current_widths: &Entity, cx: &mut Context, ) -> AnyElement { - let cols = widths.len(); + let cols = current_widths.read(cx).cols(); // Create headers array with interactive elements let mut headers = Vec::with_capacity(cols); @@ -78,8 +54,7 @@ impl CsvPreviewView { Table::new(cols) .interactable(&self.table_interaction_state) .striped() - .column_widths(widths) - .resizable_columns(resize_behaviors, current_widths, cx) + .width_config(ColumnWidthConfig::redistributable(current_widths.clone())) .header(headers) .disable_base_style() .map(|table| { diff --git a/crates/csv_preview/src/renderer/row_identifiers.rs b/crates/csv_preview/src/renderer/row_identifiers.rs index a122aa9bf3d803b9deb9c6211e117ba4aa593d93..fc8bf68845fd41917e7d60bf5f9276295534c902 100644 --- a/crates/csv_preview/src/renderer/row_identifiers.rs +++ b/crates/csv_preview/src/renderer/row_identifiers.rs @@ -139,6 +139,7 @@ impl CsvPreviewView { RowIdentifiers::SrcLines => RowIdentifiers::RowNum, RowIdentifiers::RowNum => RowIdentifiers::SrcLines, }; + this.sync_column_widths(cx); cx.notify(); }); }), diff --git a/crates/csv_preview/src/renderer/table_cell.rs b/crates/csv_preview/src/renderer/table_cell.rs index 32900ab77708936e218e9af10a4de5fba796e6a7..733488110fbcdb39761b150a74c135426ca6514a 100644 --- a/crates/csv_preview/src/renderer/table_cell.rs +++ b/crates/csv_preview/src/renderer/table_cell.rs @@ -53,7 +53,6 @@ fn create_table_cell( .px_1() .bg(cx.theme().colors().editor_background) .border_b_1() - .border_r_1() .border_color(cx.theme().colors().border_variant) .map(|div| match vertical_alignment { VerticalAlignment::Top => div.items_start(), diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index d169ba686098dddd4881915ece11c8a97148affa..a66e840b2f41405b5c76f3999ea14414daa19d39 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -41,9 +41,9 @@ use theme::AccentColors; use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, HighlightedLabel, - ScrollableHandle, Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, - Tooltip, WithScrollbar, prelude::*, + ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, + HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, + TableResizeBehavior, Tooltip, WithScrollbar, prelude::*, }; use workspace::{ Workspace, @@ -901,7 +901,7 @@ pub struct GitGraph { context_menu: Option<(Entity, Point, Subscription)>, row_height: Pixels, table_interaction_state: Entity, - table_column_widths: Entity, + table_column_widths: Entity, horizontal_scroll_offset: Pixels, graph_viewport_width: Pixels, selected_entry_idx: Option, @@ -972,7 +972,23 @@ impl GitGraph { }); let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx)); - let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx)); + let table_column_widths = cx.new(|_cx| { + RedistributableColumnsState::new( + 4, + vec![ + DefiniteLength::Fraction(0.72), + DefiniteLength::Fraction(0.12), + DefiniteLength::Fraction(0.10), + DefiniteLength::Fraction(0.06), + ], + vec![ + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + ], + ) + }); let mut row_height = Self::row_height(cx); cx.observe_global_in::(window, move |this, _window, cx| { @@ -2459,11 +2475,6 @@ impl Render for GitGraph { self.search_state.state = QueryState::Empty; self.search(query, cx); } - let description_width_fraction = 0.72; - let date_width_fraction = 0.12; - let author_width_fraction = 0.10; - let commit_width_fraction = 0.06; - let (commit_count, is_loading) = match self.graph_data.max_commit_count { AllCommitCount::Loaded(count) => (count, true), AllCommitCount::NotLoaded => { @@ -2523,7 +2534,10 @@ impl Render for GitGraph { .flex_col() .child( div() - .p_2() + .flex() + .items_center() + .px_1() + .py_0p5() .border_b_1() .whitespace_nowrap() .border_color(cx.theme().colors().border) @@ -2565,25 +2579,9 @@ impl Render for GitGraph { Label::new("Author").color(Color::Muted).into_any_element(), Label::new("Commit").color(Color::Muted).into_any_element(), ]) - .column_widths( - [ - DefiniteLength::Fraction(description_width_fraction), - DefiniteLength::Fraction(date_width_fraction), - DefiniteLength::Fraction(author_width_fraction), - DefiniteLength::Fraction(commit_width_fraction), - ] - .to_vec(), - ) - .resizable_columns( - vec![ - TableResizeBehavior::Resizable, - TableResizeBehavior::Resizable, - TableResizeBehavior::Resizable, - TableResizeBehavior::Resizable, - ], - &self.table_column_widths, - cx, - ) + .width_config(ColumnWidthConfig::redistributable( + self.table_column_widths.clone(), + )) .map_row(move |(index, row), window, cx| { let is_selected = selected_entry_idx == Some(index); let is_hovered = hovered_entry_idx == Some(index); diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 6a02289353f7fc0df8fd2b3fd99313d2ce650951..2e3172dac95fe91ed5b2a5a187ca57bbd9154fae 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -31,10 +31,10 @@ use settings::{ BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets, infer_json_indent_size, }; use ui::{ - ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, IconPosition, - Indicator, Modal, ModalFooter, ModalHeader, ParentElement as _, PopoverMenu, Render, Section, - SharedString, Styled as _, Table, TableColumnWidths, TableInteractionState, - TableResizeBehavior, Tooltip, Window, prelude::*, + ActiveTheme as _, App, Banner, BorrowAppContext, ColumnWidthConfig, ContextMenu, + IconButtonShape, IconPosition, Indicator, Modal, ModalFooter, ModalHeader, ParentElement as _, + PopoverMenu, RedistributableColumnsState, Render, Section, SharedString, Styled as _, Table, + TableInteractionState, TableResizeBehavior, Tooltip, Window, prelude::*, }; use ui_input::InputField; use util::ResultExt; @@ -450,7 +450,7 @@ struct KeymapEditor { context_menu: Option<(Entity, Point, Subscription)>, previous_edit: Option, humanized_action_names: HumanizedActionNameCache, - current_widths: Entity, + current_widths: Entity, show_hover_menus: bool, actions_with_schemas: HashSet<&'static str>, /// In order for the JSON LSP to run in the actions arguments editor, we @@ -623,7 +623,27 @@ impl KeymapEditor { actions_with_schemas: HashSet::default(), action_args_temp_dir: None, action_args_temp_dir_worktree: None, - current_widths: cx.new(|cx| TableColumnWidths::new(COLS, cx)), + current_widths: cx.new(|_cx| { + RedistributableColumnsState::new( + COLS, + vec![ + DefiniteLength::Absolute(AbsoluteLength::Pixels(px(36.))), + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.20), + DefiniteLength::Fraction(0.14), + DefiniteLength::Fraction(0.45), + DefiniteLength::Fraction(0.08), + ], + vec![ + TableResizeBehavior::None, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + ], + ) + }), }; this.on_keymap_changed(window, cx); @@ -2095,26 +2115,9 @@ impl Render for KeymapEditor { let this = cx.entity(); move |window, cx| this.read(cx).render_no_matches_hint(window, cx) }) - .column_widths(vec![ - DefiniteLength::Absolute(AbsoluteLength::Pixels(px(36.))), - DefiniteLength::Fraction(0.25), - DefiniteLength::Fraction(0.20), - DefiniteLength::Fraction(0.14), - DefiniteLength::Fraction(0.45), - DefiniteLength::Fraction(0.08), - ]) - .resizable_columns( - vec![ - TableResizeBehavior::None, - TableResizeBehavior::Resizable, - TableResizeBehavior::Resizable, - TableResizeBehavior::Resizable, - TableResizeBehavior::Resizable, - TableResizeBehavior::Resizable, // this column doesn't matter - ], - &self.current_widths, - cx, - ) + .width_config(ColumnWidthConfig::redistributable( + self.current_widths.clone(), + )) .header(vec!["", "Action", "Arguments", "Keystrokes", "Context", "Source"]) .uniform_list( "keymap-editor-table", diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 3da30838ca8313b68608e432ce1e76870157c1fd..2012defc47d9cccea87849fa41470ad1183b552f 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -1,14 +1,15 @@ use std::{ops::Range, rc::Rc}; use gpui::{ - AbsoluteLength, AppContext, Context, DefiniteLength, DragMoveEvent, Entity, EntityId, - FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, - Stateful, UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list, + AbsoluteLength, AppContext as _, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle, + Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, Stateful, + UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list, }; +use itertools::intersperse_with; use crate::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, - ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator, + ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex, @@ -16,20 +17,20 @@ use crate::{ table_row::{IntoTableRow as _, TableRow}, v_flex, }; -use itertools::intersperse_with; pub mod table_row; #[cfg(test)] mod tests; const RESIZE_COLUMN_WIDTH: f32 = 8.0; +const RESIZE_DIVIDER_WIDTH: f32 = 1.0; /// Represents an unchecked table row, which is a vector of elements. /// Will be converted into `TableRow` internally pub type UncheckedTableRow = Vec; #[derive(Debug)] -struct DraggedColumn(usize); +pub(crate) struct DraggedColumn(pub(crate) usize); struct UniformListData { render_list_of_rows_fn: @@ -110,106 +111,103 @@ impl TableInteractionState { view.update(cx, |view, cx| f(view, e, window, cx)).ok(); } } +} - /// Renders invisible resize handles overlaid on top of table content. - /// - /// - Spacer: invisible element that matches the width of table column content - /// - Divider: contains the actual resize handle that users can drag to resize columns - /// - /// Structure: [spacer] [divider] [spacer] [divider] [spacer] - /// - /// Business logic: - /// 1. Creates spacers matching each column width - /// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns) - /// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize - /// 4. Returns an absolute-positioned overlay that sits on top of table content - fn render_resize_handles( - &self, - column_widths: &TableRow, - resizable_columns: &TableRow, - initial_sizes: &TableRow, - columns: Option>, - window: &mut Window, - cx: &mut App, - ) -> AnyElement { - let spacers = column_widths - .as_slice() - .iter() - .map(|width| base_cell_style(Some(*width)).into_any_element()); - - let mut column_ix = 0; - let resizable_columns_shared = Rc::new(resizable_columns.clone()); - let initial_sizes_shared = Rc::new(initial_sizes.clone()); - let mut resizable_columns_iter = resizable_columns.as_slice().iter(); - - // Insert dividers between spacers (column content) - let dividers = intersperse_with(spacers, || { - let resizable_columns = Rc::clone(&resizable_columns_shared); - let initial_sizes = Rc::clone(&initial_sizes_shared); - window.with_id(column_ix, |window| { - let mut resize_divider = div() - // This is required because this is evaluated at a different time than the use_state call above - .id(column_ix) - .relative() - .top_0() - .w_px() - .h_full() - .bg(cx.theme().colors().border.opacity(0.8)); - - let mut resize_handle = div() - .id("column-resize-handle") - .absolute() - .left_neg_0p5() - .w(px(RESIZE_COLUMN_WIDTH)) - .h_full(); - - if resizable_columns_iter - .next() - .is_some_and(TableResizeBehavior::is_resizable) - { - let hovered = window.use_state(cx, |_window, _cx| false); - - resize_divider = resize_divider.when(*hovered.read(cx), |div| { - div.bg(cx.theme().colors().border_focused) - }); - - resize_handle = resize_handle - .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered)) - .cursor_col_resize() - .when_some(columns.clone(), |this, columns| { - this.on_click(move |event, window, cx| { - if event.click_count() >= 2 { - columns.update(cx, |columns, _| { - columns.on_double_click( - column_ix, - &initial_sizes, - &resizable_columns, - window, - ); - }) - } +/// Renders invisible resize handles overlaid on top of table content. +/// +/// - Spacer: invisible element that matches the width of table column content +/// - Divider: contains the actual resize handle that users can drag to resize columns +/// +/// Structure: [spacer] [divider] [spacer] [divider] [spacer] +/// +/// Business logic: +/// 1. Creates spacers matching each column width +/// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns) +/// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize +/// 4. Returns an absolute-positioned overlay that sits on top of table content +fn render_resize_handles( + column_widths: &TableRow, + resizable_columns: &TableRow, + initial_sizes: &TableRow, + columns: Option>, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let spacers = column_widths + .as_slice() + .iter() + .map(|width| base_cell_style(Some(*width)).into_any_element()); + + let mut column_ix = 0; + let resizable_columns_shared = Rc::new(resizable_columns.clone()); + let initial_sizes_shared = Rc::new(initial_sizes.clone()); + let mut resizable_columns_iter = resizable_columns.as_slice().iter(); + + let dividers = intersperse_with(spacers, || { + let resizable_columns = Rc::clone(&resizable_columns_shared); + let initial_sizes = Rc::clone(&initial_sizes_shared); + window.with_id(column_ix, |window| { + let mut resize_divider = div() + .id(column_ix) + .relative() + .top_0() + .w(px(RESIZE_DIVIDER_WIDTH)) + .h_full() + .bg(cx.theme().colors().border.opacity(0.8)); + + let mut resize_handle = div() + .id("column-resize-handle") + .absolute() + .left_neg_0p5() + .w(px(RESIZE_COLUMN_WIDTH)) + .h_full(); + + if resizable_columns_iter + .next() + .is_some_and(TableResizeBehavior::is_resizable) + { + let hovered = window.use_state(cx, |_window, _cx| false); + + resize_divider = resize_divider.when(*hovered.read(cx), |div| { + div.bg(cx.theme().colors().border_focused) + }); + + resize_handle = resize_handle + .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered)) + .cursor_col_resize() + .when_some(columns.clone(), |this, columns| { + this.on_click(move |event, window, cx| { + if event.click_count() >= 2 { + columns.update(cx, |columns, _| { + columns.on_double_click( + column_ix, + &initial_sizes, + &resizable_columns, + window, + ); + }) + } - cx.stop_propagation(); - }) + cx.stop_propagation(); }) - .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| { - cx.new(|_cx| gpui::Empty) - }) - } + }) + .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| { + cx.new(|_cx| gpui::Empty) + }) + } - column_ix += 1; - resize_divider.child(resize_handle).into_any_element() - }) - }); + column_ix += 1; + resize_divider.child(resize_handle).into_any_element() + }) + }); - h_flex() - .id("resize-handles") - .absolute() - .inset_0() - .w_full() - .children(dividers) - .into_any_element() - } + h_flex() + .id("resize-handles") + .absolute() + .inset_0() + .w_full() + .children(dividers) + .into_any_element() } #[derive(Debug, Copy, Clone, PartialEq)] @@ -233,25 +231,181 @@ impl TableResizeBehavior { } } -pub struct TableColumnWidths { - widths: TableRow, - visible_widths: TableRow, - cached_bounds_width: Pixels, - initialized: bool, +pub enum ColumnWidthConfig { + /// Static column widths (no resize handles). + Static { + widths: StaticColumnWidths, + /// Controls widths of the whole table. + table_width: Option, + }, + /// Redistributable columns — dragging redistributes the fixed available space + /// among columns without changing the overall table width. + Redistributable { + columns_state: Entity, + table_width: Option, + }, +} + +pub enum StaticColumnWidths { + /// All columns share space equally (flex-1 / Length::Auto). + Auto, + /// Each column has a specific width. + Explicit(TableRow), } -impl TableColumnWidths { - pub fn new(cols: usize, _: &mut App) -> Self { +impl ColumnWidthConfig { + /// Auto-width columns, auto-size table. + pub fn auto() -> Self { + ColumnWidthConfig::Static { + widths: StaticColumnWidths::Auto, + table_width: None, + } + } + + /// Redistributable columns with no fixed table width. + pub fn redistributable(columns_state: Entity) -> Self { + ColumnWidthConfig::Redistributable { + columns_state, + table_width: None, + } + } + + /// Auto-width columns, fixed table width. + pub fn auto_with_table_width(width: impl Into) -> Self { + ColumnWidthConfig::Static { + widths: StaticColumnWidths::Auto, + table_width: Some(width.into()), + } + } + + /// Column widths for rendering. + pub fn widths_to_render(&self, cx: &App) -> Option> { + match self { + ColumnWidthConfig::Static { + widths: StaticColumnWidths::Auto, + .. + } => None, + ColumnWidthConfig::Static { + widths: StaticColumnWidths::Explicit(widths), + .. + } => Some(widths.map_cloned(Length::Definite)), + ColumnWidthConfig::Redistributable { + columns_state: entity, + .. + } => { + let state = entity.read(cx); + Some(state.preview_widths.map_cloned(Length::Definite)) + } + } + } + + /// Table-level width. + pub fn table_width(&self) -> Option { + match self { + ColumnWidthConfig::Static { table_width, .. } + | ColumnWidthConfig::Redistributable { table_width, .. } => { + table_width.map(Length::Definite) + } + } + } + + /// ListHorizontalSizingBehavior for uniform_list. + pub fn list_horizontal_sizing(&self) -> ListHorizontalSizingBehavior { + match self.table_width() { + Some(_) => ListHorizontalSizingBehavior::Unconstrained, + None => ListHorizontalSizingBehavior::FitList, + } + } + + /// Render resize handles overlay if applicable. + pub fn render_resize_handles(&self, window: &mut Window, cx: &mut App) -> Option { + match self { + ColumnWidthConfig::Redistributable { + columns_state: entity, + .. + } => { + let (column_widths, resize_behavior, initial_widths) = { + let state = entity.read(cx); + ( + state.preview_widths.map_cloned(Length::Definite), + state.resize_behavior.clone(), + state.initial_widths.clone(), + ) + }; + Some(render_resize_handles( + &column_widths, + &resize_behavior, + &initial_widths, + Some(entity.clone()), + window, + cx, + )) + } + _ => None, + } + } + + /// Returns info needed for header double-click-to-reset, if applicable. + pub fn header_resize_info(&self, cx: &App) -> Option { + match self { + ColumnWidthConfig::Redistributable { columns_state, .. } => { + let state = columns_state.read(cx); + Some(HeaderResizeInfo { + columns_state: columns_state.downgrade(), + resize_behavior: state.resize_behavior.clone(), + initial_widths: state.initial_widths.clone(), + }) + } + _ => None, + } + } +} + +#[derive(Clone)] +pub struct HeaderResizeInfo { + pub columns_state: WeakEntity, + pub resize_behavior: TableRow, + pub initial_widths: TableRow, +} + +pub struct RedistributableColumnsState { + pub(crate) initial_widths: TableRow, + pub(crate) committed_widths: TableRow, + pub(crate) preview_widths: TableRow, + pub(crate) resize_behavior: TableRow, + pub(crate) cached_table_width: Pixels, +} + +impl RedistributableColumnsState { + pub fn new( + cols: usize, + initial_widths: UncheckedTableRow>, + resize_behavior: UncheckedTableRow, + ) -> Self { + let widths: TableRow = initial_widths + .into_iter() + .map(Into::into) + .collect::>() + .into_table_row(cols); Self { - widths: vec![DefiniteLength::default(); cols].into_table_row(cols), - visible_widths: vec![DefiniteLength::default(); cols].into_table_row(cols), - cached_bounds_width: Default::default(), - initialized: false, + initial_widths: widths.clone(), + committed_widths: widths.clone(), + preview_widths: widths, + resize_behavior: resize_behavior.into_table_row(cols), + cached_table_width: Default::default(), } } pub fn cols(&self) -> usize { - self.widths.cols() + self.committed_widths.cols() + } + + pub fn initial_widths(&self) -> &TableRow { + &self.initial_widths + } + + pub fn resize_behavior(&self) -> &TableRow { + &self.resize_behavior } fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 { @@ -264,19 +418,19 @@ impl TableColumnWidths { } } - fn on_double_click( + pub(crate) fn on_double_click( &mut self, double_click_position: usize, initial_sizes: &TableRow, resize_behavior: &TableRow, window: &mut Window, ) { - let bounds_width = self.cached_bounds_width; + let bounds_width = self.cached_table_width; let rem_size = window.rem_size(); let initial_sizes = initial_sizes.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); let widths = self - .widths + .committed_widths .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); let updated_widths = Self::reset_to_initial_size( @@ -285,53 +439,16 @@ impl TableColumnWidths { initial_sizes, resize_behavior, ); - self.widths = updated_widths.map(DefiniteLength::Fraction); - self.visible_widths = self.widths.clone(); // previously was copy + self.committed_widths = updated_widths.map(DefiniteLength::Fraction); + self.preview_widths = self.committed_widths.clone(); } - fn reset_to_initial_size( + pub(crate) fn reset_to_initial_size( col_idx: usize, mut widths: TableRow, initial_sizes: TableRow, resize_behavior: &TableRow, ) -> TableRow { - // RESET: - // Part 1: - // Figure out if we should shrink/grow the selected column - // Get diff which represents the change in column we want to make initial size delta curr_size = diff - // - // Part 2: We need to decide which side column we should move and where - // - // If we want to grow our column we should check the left/right columns diff to see what side - // has a greater delta than their initial size. Likewise, if we shrink our column we should check - // the left/right column diffs to see what side has the smallest delta. - // - // Part 3: resize - // - // col_idx represents the column handle to the right of an active column - // - // If growing and right has the greater delta { - // shift col_idx to the right - // } else if growing and left has the greater delta { - // shift col_idx - 1 to the left - // } else if shrinking and the right has the greater delta { - // shift - // } { - // - // } - // } - // - // if we need to shrink, then if the right - // - - // DRAGGING - // we get diff which represents the change in the _drag handle_ position - // -diff => dragging left -> - // grow the column to the right of the handle as much as we can shrink columns to the left of the handle - // +diff => dragging right -> growing handles column - // grow the column to the left of the handle as much as we can shrink columns to the right of the handle - // - let diff = initial_sizes[col_idx] - widths[col_idx]; let left_diff = @@ -376,10 +493,9 @@ impl TableColumnWidths { widths } - fn on_drag_move( + pub(crate) fn on_drag_move( &mut self, drag_event: &DragMoveEvent, - resize_behavior: &TableRow, window: &mut Window, cx: &mut Context, ) { @@ -391,43 +507,42 @@ impl TableColumnWidths { let bounds_width = bounds.right() - bounds.left(); let col_idx = drag_event.drag(cx).0; - let column_handle_width = Self::get_fraction( - &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_COLUMN_WIDTH))), + let divider_width = Self::get_fraction( + &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))), bounds_width, rem_size, ); let mut widths = self - .widths + .committed_widths .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); for length in widths[0..=col_idx].iter() { - col_position += length + column_handle_width; + col_position += length + divider_width; } let mut total_length_ratio = col_position; for length in widths[col_idx + 1..].iter() { total_length_ratio += length; } - let cols = resize_behavior.cols(); - total_length_ratio += (cols - 1 - col_idx) as f32 * column_handle_width; + let cols = self.resize_behavior.cols(); + total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width; let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; let drag_fraction = drag_fraction * total_length_ratio; - let diff = drag_fraction - col_position - column_handle_width / 2.0; + let diff = drag_fraction - col_position - divider_width / 2.0; - Self::drag_column_handle(diff, col_idx, &mut widths, resize_behavior); + Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior); - self.visible_widths = widths.map(DefiniteLength::Fraction); + self.preview_widths = widths.map(DefiniteLength::Fraction); } - fn drag_column_handle( + pub(crate) fn drag_column_handle( diff: f32, col_idx: usize, widths: &mut TableRow, resize_behavior: &TableRow, ) { - // if diff > 0.0 then go right if diff > 0.0 { Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1); } else { @@ -435,7 +550,7 @@ impl TableColumnWidths { } } - fn propagate_resize_diff( + pub(crate) fn propagate_resize_diff( diff: f32, col_idx: usize, widths: &mut TableRow, @@ -493,44 +608,16 @@ impl TableColumnWidths { } } -pub struct TableWidths { - initial: TableRow, - current: Option>, - resizable: TableRow, -} - -impl TableWidths { - pub fn new(widths: TableRow>) -> Self { - let widths = widths.map(Into::into); - - let expected_length = widths.cols(); - TableWidths { - initial: widths, - current: None, - resizable: vec![TableResizeBehavior::None; expected_length] - .into_table_row(expected_length), - } - } - - fn lengths(&self, cx: &App) -> TableRow { - self.current - .as_ref() - .map(|entity| entity.read(cx).visible_widths.map_cloned(Length::Definite)) - .unwrap_or_else(|| self.initial.map_cloned(Length::Definite)) - } -} - /// A table component #[derive(RegisterComponent, IntoElement)] pub struct Table { striped: bool, show_row_borders: bool, show_row_hover: bool, - width: Option, headers: Option>, rows: TableContents, interaction_state: Option>, - col_widths: Option, + column_width_config: ColumnWidthConfig, map_row: Option), &mut Window, &mut App) -> AnyElement>>, use_ui_font: bool, empty_table_callback: Option AnyElement>>, @@ -547,15 +634,14 @@ impl Table { striped: false, show_row_borders: true, show_row_hover: true, - width: None, headers: None, rows: TableContents::Vec(Vec::new()), interaction_state: None, map_row: None, use_ui_font: true, empty_table_callback: None, - col_widths: None, disable_base_cell_style: false, + column_width_config: ColumnWidthConfig::auto(), } } @@ -626,10 +712,18 @@ impl Table { self } - /// Sets the width of the table. - /// Will enable horizontal scrolling if [`Self::interactable`] is also called. - pub fn width(mut self, width: impl Into) -> Self { - self.width = Some(width.into()); + /// Sets a fixed table width with auto column widths. + /// + /// This is a shorthand for `.width_config(ColumnWidthConfig::auto_with_table_width(width))`. + /// For resizable columns or explicit column widths, use [`Table::width_config`] directly. + pub fn width(mut self, width: impl Into) -> Self { + self.column_width_config = ColumnWidthConfig::auto_with_table_width(width); + self + } + + /// Sets the column width configuration for the table. + pub fn width_config(mut self, config: ColumnWidthConfig) -> Self { + self.column_width_config = config; self } @@ -637,10 +731,8 @@ impl Table { /// /// Vertical scrolling will be enabled by default if the table is taller than its container. /// - /// Horizontal scrolling will only be enabled if [`Self::width`] is also called, otherwise - /// the list will always shrink the table columns to fit their contents I.e. If [`Self::uniform_list`] - /// is used without a width and with [`Self::interactable`], the [`ListHorizontalSizingBehavior`] will - /// be set to [`ListHorizontalSizingBehavior::FitList`]. + /// Horizontal scrolling will only be enabled if a table width is set via [`ColumnWidthConfig`], + /// otherwise the list will always shrink the table columns to fit their contents. pub fn interactable(mut self, interaction_state: &Entity) -> Self { self.interaction_state = Some(interaction_state.downgrade()); self @@ -666,36 +758,6 @@ impl Table { self } - pub fn column_widths(mut self, widths: UncheckedTableRow>) -> Self { - if self.col_widths.is_none() { - self.col_widths = Some(TableWidths::new(widths.into_table_row(self.cols))); - } - self - } - - pub fn resizable_columns( - mut self, - resizable: UncheckedTableRow, - column_widths: &Entity, - cx: &mut App, - ) -> Self { - if let Some(table_widths) = self.col_widths.as_mut() { - table_widths.resizable = resizable.into_table_row(self.cols); - let column_widths = table_widths - .current - .get_or_insert_with(|| column_widths.clone()); - - column_widths.update(cx, |widths, _| { - if !widths.initialized { - widths.initialized = true; - widths.widths = table_widths.initial.clone(); - widths.visible_widths = widths.widths.clone(); - } - }) - } - self - } - pub fn no_ui_font(mut self) -> Self { self.use_ui_font = false; self @@ -812,11 +874,7 @@ pub fn render_table_row( pub fn render_table_header( headers: TableRow, table_context: TableRenderContext, - columns_widths: Option<( - WeakEntity, - TableRow, - TableRow, - )>, + resize_info: Option, entity_id: Option, cx: &mut App, ) -> impl IntoElement { @@ -837,9 +895,7 @@ pub fn render_table_header( .flex() .flex_row() .items_center() - .justify_between() .w_full() - .p_2() .border_b_1() .border_color(cx.theme().colors().border) .children( @@ -850,34 +906,33 @@ pub fn render_table_header( .zip(column_widths.into_vec()) .map(|((header_idx, h), width)| { base_cell_style_text(width, table_context.use_ui_font, cx) + .px_1() + .py_0p5() .child(h) .id(ElementId::NamedInteger( shared_element_id.clone(), header_idx as u64, )) - .when_some( - columns_widths.as_ref().cloned(), - |this, (column_widths, resizables, initial_sizes)| { - if resizables[header_idx].is_resizable() { - this.on_click(move |event, window, cx| { - if event.click_count() > 1 { - column_widths - .update(cx, |column, _| { - column.on_double_click( - header_idx, - &initial_sizes, - &resizables, - window, - ); - }) - .ok(); - } - }) - } else { - this - } - }, - ) + .when_some(resize_info.as_ref().cloned(), |this, info| { + if info.resize_behavior[header_idx].is_resizable() { + this.on_click(move |event, window, cx| { + if event.click_count() > 1 { + info.columns_state + .update(cx, |column, _| { + column.on_double_click( + header_idx, + &info.initial_widths, + &info.resize_behavior, + window, + ); + }) + .ok(); + } + }) + } else { + this + } + }) }), ) } @@ -901,7 +956,7 @@ impl TableRenderContext { show_row_borders: table.show_row_borders, show_row_hover: table.show_row_hover, total_row_count: table.rows.len(), - column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)), + column_widths: table.column_width_config.widths_to_render(cx), map_row: table.map_row.clone(), use_ui_font: table.use_ui_font, disable_base_cell_style: table.disable_base_cell_style, @@ -913,48 +968,52 @@ impl RenderOnce for Table { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let table_context = TableRenderContext::new(&self, cx); let interaction_state = self.interaction_state.and_then(|state| state.upgrade()); - let current_widths = self - .col_widths - .as_ref() - .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable.clone()))) - .map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior)); - let current_widths_with_initial_sizes = self - .col_widths + let header_resize_info = interaction_state .as_ref() - .and_then(|widths| { - Some(( - widths.current.as_ref()?, - widths.resizable.clone(), - widths.initial.clone(), - )) - }) - .map(|(curr, resize_behavior, initial)| (curr.downgrade(), resize_behavior, initial)); + .and_then(|_| self.column_width_config.header_resize_info(cx)); - let width = self.width; + let table_width = self.column_width_config.table_width(); + let horizontal_sizing = self.column_width_config.list_horizontal_sizing(); let no_rows_rendered = self.rows.is_empty(); + // Extract redistributable entity for drag/drop/prepaint handlers + let redistributable_entity = + interaction_state + .as_ref() + .and_then(|_| match &self.column_width_config { + ColumnWidthConfig::Redistributable { + columns_state: entity, + .. + } => Some(entity.downgrade()), + _ => None, + }); + + let resize_handles = interaction_state + .as_ref() + .and_then(|_| self.column_width_config.render_resize_handles(window, cx)); + let table = div() - .when_some(width, |this, width| this.w(width)) + .when_some(table_width, |this, width| this.w(width)) .h_full() .v_flex() .when_some(self.headers.take(), |this, headers| { this.child(render_table_header( headers, table_context.clone(), - current_widths_with_initial_sizes, + header_resize_info, interaction_state.as_ref().map(Entity::entity_id), cx, )) }) - .when_some(current_widths, { - |this, (widths, resize_behavior)| { + .when_some(redistributable_entity, { + |this, widths| { this.on_drag_move::({ let widths = widths.clone(); move |e, window, cx| { widths .update(cx, |widths, cx| { - widths.on_drag_move(e, &resize_behavior, window, cx); + widths.on_drag_move(e, window, cx); }) .ok(); } @@ -965,7 +1024,7 @@ impl RenderOnce for Table { widths .update(cx, |widths, _| { // This works because all children x axis bounds are the same - widths.cached_bounds_width = + widths.cached_table_width = bounds[0].right() - bounds[0].left(); }) .ok(); @@ -974,10 +1033,9 @@ impl RenderOnce for Table { .on_drop::(move |_, _, cx| { widths .update(cx, |widths, _| { - widths.widths = widths.visible_widths.clone(); + widths.committed_widths = widths.preview_widths.clone(); }) .ok(); - // Finish the resize operation }) } }) @@ -1029,11 +1087,7 @@ impl RenderOnce for Table { .size_full() .flex_grow() .with_sizing_behavior(ListSizingBehavior::Auto) - .with_horizontal_sizing_behavior(if width.is_some() { - ListHorizontalSizingBehavior::Unconstrained - } else { - ListHorizontalSizingBehavior::FitList - }) + .with_horizontal_sizing_behavior(horizontal_sizing) .when_some( interaction_state.as_ref(), |this, state| { @@ -1063,25 +1117,7 @@ impl RenderOnce for Table { .with_sizing_behavior(ListSizingBehavior::Auto), ), }) - .when_some( - self.col_widths.as_ref().zip(interaction_state.as_ref()), - |parent, (table_widths, state)| { - parent.child(state.update(cx, |state, cx| { - let resizable_columns = &table_widths.resizable; - let column_widths = table_widths.lengths(cx); - let columns = table_widths.current.clone(); - let initial_sizes = &table_widths.initial; - state.render_resize_handles( - &column_widths, - resizable_columns, - initial_sizes, - columns, - window, - cx, - ) - })) - }, - ); + .when_some(resize_handles, |parent, handles| parent.child(handles)); if let Some(state) = interaction_state.as_ref() { let scrollbars = state diff --git a/crates/ui/src/components/data_table/tests.rs b/crates/ui/src/components/data_table/tests.rs index f0982a8aa5abe5f5a9351ebaaaf4072ca17839e6..0936cd3088cc50bc08bf0a0a09d9a6fa7a2cdaf0 100644 --- a/crates/ui/src/components/data_table/tests.rs +++ b/crates/ui/src/components/data_table/tests.rs @@ -82,7 +82,7 @@ mod reset_column_size { let cols = initial_sizes.len(); let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols); let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols); - let result = TableColumnWidths::reset_to_initial_size( + let result = RedistributableColumnsState::reset_to_initial_size( column_index, TableRow::from_vec(widths, cols), TableRow::from_vec(initial_sizes, cols), @@ -259,7 +259,7 @@ mod drag_handle { let distance = distance as f32 / total_1; let mut widths_table_row = TableRow::from_vec(widths, cols); - TableColumnWidths::drag_column_handle( + RedistributableColumnsState::drag_column_handle( distance, column_index, &mut widths_table_row, From a9dd7e9f062933336b04c07145caaf19893c0f89 Mon Sep 17 00:00:00 2001 From: danielaalves01 <56894701+danielaalves01@users.noreply.github.com> Date: Wed, 1 Apr 2026 06:46:19 +0100 Subject: [PATCH 12/22] Fix workspace-absolute paths in markdown images (#52708) ## Context Previously, markdown images failed to load workspace-absolute paths. This updates the image resolver to identify the active workspace root directory. Paths which are workspace absolute are correctly resolved and rendered. The added test covers a successful resolution. This PR re-implements the fix that was originally proposed in my previous PR, #52178. ## Fix https://github.com/user-attachments/assets/d69644ea-06cc-4638-b4ee-ec9f3abbb1ed ## How to Review Small PR - focus on two changes in the file `crates/markdown_preview/src/markdown_preview_view.rs`: - `fn render_markdown_element()` (lines ~583-590): added the logic to determine the workspace_directory - `fn resolve_preview_image()` (lines ~714-726): added workspace_directory variable, and a verification to create the full path when a path is workspace-absolute One test was added, covering a successful resolution (`resolves_workspace_absolute_preview_images`). This test was implemented in the file `crates/markdown_preview/src/markdown_preview_view.rs`. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #46924 Release Notes: - Added workspace-absolute path detection in markdown files --- .../src/markdown_preview_view.rs | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 0b9c63c3b16f5686afcfdafdba119ede8c37fe3f..8e289e451dada6170f7b2bd7282ef9f165d26cff 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -580,6 +580,14 @@ impl MarkdownPreviewView { .as_ref() .map(|state| state.editor.clone()); + let mut workspace_directory = None; + if let Some(workspace_entity) = self.workspace.upgrade() { + let project = workspace_entity.read(cx).project(); + if let Some(tree) = project.read(cx).worktrees(cx).next() { + workspace_directory = Some(tree.read(cx).abs_path().to_path_buf()); + } + } + let mut markdown_element = MarkdownElement::new( self.markdown.clone(), MarkdownStyle::themed(MarkdownFont::Editor, window, cx), @@ -593,7 +601,13 @@ impl MarkdownPreviewView { .show_root_block_markers() .image_resolver({ let base_directory = self.base_directory.clone(); - move |dest_url| resolve_preview_image(dest_url, base_directory.as_deref()) + move |dest_url| { + resolve_preview_image( + dest_url, + base_directory.as_deref(), + workspace_directory.as_deref(), + ) + } }) .on_url_click(move |url, window, cx| { open_preview_url(url, base_directory.clone(), &workspace, window, cx); @@ -687,7 +701,11 @@ fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option) -> Option { +fn resolve_preview_image( + dest_url: &str, + base_directory: Option<&Path>, + workspace_directory: Option<&Path>, +) -> Option { if dest_url.starts_with("data:") { return None; } @@ -702,6 +720,19 @@ fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Optio .map(|decoded| decoded.into_owned()) .unwrap_or_else(|_| dest_url.to_string()); + let decoded_path = Path::new(&decoded); + + if let Ok(relative_path) = decoded_path.strip_prefix("/") { + if let Some(root) = workspace_directory { + let absolute_path = root.join(relative_path); + if absolute_path.exists() { + return Some(ImageSource::Resource(Resource::Path(Arc::from( + absolute_path.as_path(), + )))); + } + } + } + let path = if Path::new(&decoded).is_absolute() { PathBuf::from(decoded) } else { @@ -778,6 +809,9 @@ impl Render for MarkdownPreviewView { #[cfg(test)] mod tests { + use crate::markdown_preview_view::ImageSource; + use crate::markdown_preview_view::Resource; + use crate::markdown_preview_view::resolve_preview_image; use anyhow::Result; use std::fs; use tempfile::TempDir; @@ -819,6 +853,54 @@ mod tests { Ok(()) } + #[test] + fn resolves_workspace_absolute_preview_images() -> Result<()> { + let temp_dir = TempDir::new()?; + let workspace_directory = temp_dir.path(); + + let base_directory = workspace_directory.join("docs"); + fs::create_dir_all(&base_directory)?; + + let image_file = workspace_directory.join("test_image.png"); + fs::write(&image_file, "mock data")?; + + let resolved_success = resolve_preview_image( + "/test_image.png", + Some(&base_directory), + Some(workspace_directory), + ); + + match resolved_success { + Some(ImageSource::Resource(Resource::Path(p))) => { + assert_eq!(p.as_ref(), image_file.as_path()); + } + _ => panic!("Expected successful resolution to be a Resource::Path"), + } + + let resolved_missing = resolve_preview_image( + "/missing_image.png", + Some(&base_directory), + Some(workspace_directory), + ); + + let expected_missing_path = if std::path::Path::new("/missing_image.png").is_absolute() { + std::path::PathBuf::from("/missing_image.png") + } else { + // join is to retain windows path prefix C:/ + #[expect(clippy::join_absolute_paths)] + base_directory.join("/missing_image.png") + }; + + match resolved_missing { + Some(ImageSource::Resource(Resource::Path(p))) => { + assert_eq!(p.as_ref(), expected_missing_path.as_path()); + } + _ => panic!("Expected missing file to fallback to a Resource::Path"), + } + + Ok(()) + } + #[test] fn does_not_treat_web_links_as_preview_paths() { assert_eq!(resolve_preview_path("https://zed.dev", None), None); From ac204881137fd7854da3d52c0417f0c1b7626d5d Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Wed, 1 Apr 2026 14:10:23 +0530 Subject: [PATCH 13/22] eslint: Fix ESLint server startup failure on stale cached server install (#52883) Closes https://github.com/zed-industries/zed/issues/19709#issuecomment-3494789304 Closes https://github.com/zed-industries/zed/issues/24194#issuecomment-2835787560 This PR fixes case where if eslint cached install is partial or stale, Zed can try to launch a missing `eslintServer.js` and the server crashes with `MODULE_NOT_FOUND`. ``` Error: Cannot find module '/Users/.../languages/eslint/vscode-eslint-2.4.4/vscode-eslint/server/out/eslintServer.js' ``` Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed ESLint server startup failures caused by reusing an incomplete or stale cached server install. --- crates/languages/src/eslint.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index 943034652de852b2c39b4887218c3c8e28f329e1..bf51636f60bb4e0eec6eebcd3efaab2996352c18 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -148,6 +148,7 @@ impl LspInstaller for EsLintLspAdapter { ) -> Option { let server_path = Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH); + fs::metadata(&server_path).await.ok()?; Some(LanguageServerBinary { path: self.node.binary_path().await.ok()?, env: None, From 23edf066895174bf269c0a517878da26aad1b3a6 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 1 Apr 2026 10:40:31 +0200 Subject: [PATCH 14/22] sidebar: Support loading threads that have no project association (#52842) Changed the migration codepath, so that threads with no project are also migrated to the archive. Release Notes: - N/A --------- Co-authored-by: Danilo Leal --- crates/agent_ui/src/thread_metadata_store.rs | 33 +- crates/agent_ui/src/threads_archive_view.rs | 616 ++++++++++++++++++- crates/sidebar/src/sidebar.rs | 1 + 3 files changed, 634 insertions(+), 16 deletions(-) diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index d4e8e6d37aabe98dc41bf39575b77fd28a3bed08..a8b531eb59e7aab740678c464e21e4b54daa3f59 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -55,7 +55,7 @@ fn migrate_thread_metadata(cx: &mut App) { .read(cx) .entries() .filter_map(|entry| { - if existing_entries.contains(&entry.id.0) || entry.folder_paths.is_empty() { + if existing_entries.contains(&entry.id.0) { return None; } @@ -81,6 +81,9 @@ fn migrate_thread_metadata(cx: &mut App) { if is_first_migration { let mut per_project: HashMap> = HashMap::default(); for entry in &mut to_migrate { + if entry.folder_paths.is_empty() { + continue; + } per_project .entry(entry.folder_paths.clone()) .or_default() @@ -316,6 +319,25 @@ impl ThreadMetadataStore { .log_err(); } + pub fn update_working_directories( + &mut self, + session_id: &acp::SessionId, + work_dirs: PathList, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + + if let Some(thread) = self.threads.get(session_id) { + self.save_internal(ThreadMetadata { + folder_paths: work_dirs, + ..thread.clone() + }); + cx.notify(); + } + } + pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context) { self.update_archived(session_id, true, cx); } @@ -994,7 +1016,7 @@ mod tests { store.read(cx).entries().cloned().collect::>() }); - assert_eq!(list.len(), 3); + assert_eq!(list.len(), 4); assert!( list.iter() .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref()) @@ -1013,17 +1035,12 @@ mod tests { .collect::>(); assert!(migrated_session_ids.contains(&"a-session-1")); assert!(migrated_session_ids.contains(&"b-session-0")); - assert!(!migrated_session_ids.contains(&"projectless")); + assert!(migrated_session_ids.contains(&"projectless")); let migrated_entries = list .iter() .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0") .collect::>(); - assert!( - migrated_entries - .iter() - .all(|metadata| !metadata.folder_paths.is_empty()) - ); assert!(migrated_entries.iter().all(|metadata| metadata.archived)); } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 74a93129d387e0aaac6e7092d9e086dd64e369f7..9aca31e1edbe729fccecfc0dd8f0530d2aed2564 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1,3 +1,6 @@ +use std::collections::HashSet; +use std::sync::Arc; + use crate::agent_connection_store::AgentConnectionStore; use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; @@ -9,18 +12,31 @@ use agent_settings::AgentSettings; use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::Editor; use fs::Fs; +use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render, - SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px, + AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + ListState, Render, SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px, }; use itertools::Itertools as _; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use picker::{ + Picker, PickerDelegate, + highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths}, +}; use project::{AgentId, AgentServerStore}; use settings::Settings as _; use theme::ActiveTheme; use ui::ThreadItem; use ui::{ - Divider, KeyBinding, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, + Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, WithScrollbar, + prelude::*, utils::platform_title_bar_height, +}; +use ui_input::ErasedEditor; +use util::ResultExt; +use util::paths::PathExt; +use workspace::{ + ModalView, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId, + resolve_worktree_workspaces, }; use zed_actions::agents_sidebar::FocusSidebarFilter; @@ -110,12 +126,14 @@ pub struct ThreadsArchiveView { filter_editor: Entity, _subscriptions: Vec, _refresh_history_task: Task<()>, + workspace: WeakEntity, agent_connection_store: WeakEntity, agent_server_store: WeakEntity, } impl ThreadsArchiveView { pub fn new( + workspace: WeakEntity, agent_connection_store: WeakEntity, agent_server_store: WeakEntity, window: &mut Window, @@ -176,6 +194,7 @@ impl ThreadsArchiveView { thread_metadata_store_subscription, ], _refresh_history_task: Task::ready(()), + workspace, agent_connection_store, agent_server_store, }; @@ -254,7 +273,14 @@ impl ThreadsArchiveView { self.list_state.reset(items.len()); self.items = items; - self.hovered_index = None; + + if !preserve { + self.hovered_index = None; + } else if let Some(ix) = self.hovered_index { + if ix >= self.items.len() || !self.is_selectable_item(ix) { + self.hovered_index = None; + } + } if let Some(scroll_top) = saved_scroll { self.list_state.scroll_to(scroll_top); @@ -288,11 +314,57 @@ impl ThreadsArchiveView { window: &mut Window, cx: &mut Context, ) { + if thread.folder_paths.is_empty() { + self.show_project_picker_for_thread(thread, window, cx); + return; + } + self.selection = None; self.reset_filter_editor_text(window, cx); cx.emit(ThreadsArchiveViewEvent::Unarchive { thread }); } + fn show_project_picker_for_thread( + &mut self, + thread: ThreadMetadata, + window: &mut Window, + cx: &mut Context, + ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + let archive_view = cx.weak_entity(); + let fs = workspace.read(cx).app_state().fs.clone(); + let current_workspace_id = workspace.read(cx).database_id(); + let sibling_workspace_ids: HashSet = workspace + .read(cx) + .multi_workspace() + .and_then(|mw| mw.upgrade()) + .map(|mw| { + mw.read(cx) + .workspaces() + .iter() + .filter_map(|ws| ws.read(cx).database_id()) + .collect() + }) + .unwrap_or_default(); + + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + ProjectPickerModal::new( + thread, + fs, + archive_view, + current_workspace_id, + sibling_workspace_ids, + window, + cx, + ) + }); + }); + } + fn is_selectable_item(&self, ix: usize) -> bool { matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. })) } @@ -380,10 +452,6 @@ impl ThreadsArchiveView { return; }; - if thread.folder_paths.is_empty() { - return; - } - self.unarchive_thread(thread.clone(), window, cx); } @@ -471,6 +539,7 @@ impl ThreadsArchiveView { let agent = thread.agent_id.clone(); let session_id = thread.session_id.clone(); cx.listener(move |this, _, _, cx| { + this.preserve_selection_on_next_update = true; this.delete_thread(session_id.clone(), agent.clone(), cx); cx.stop_propagation(); }) @@ -683,3 +752,534 @@ impl Render for ThreadsArchiveView { .child(content) } } + +struct ProjectPickerModal { + picker: Entity>, + _subscription: Subscription, +} + +impl ProjectPickerModal { + fn new( + thread: ThreadMetadata, + fs: Arc, + archive_view: WeakEntity, + current_workspace_id: Option, + sibling_workspace_ids: HashSet, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let delegate = ProjectPickerDelegate { + thread, + archive_view, + workspaces: Vec::new(), + filtered_entries: Vec::new(), + selected_index: 0, + current_workspace_id, + sibling_workspace_ids, + focus_handle: cx.focus_handle(), + }; + + let picker = cx.new(|cx| { + Picker::list(delegate, window, cx) + .list_measure_all() + .modal(false) + }); + + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle; + }); + + let _subscription = + cx.subscribe(&picker, |_this: &mut Self, _, _event: &DismissEvent, cx| { + cx.emit(DismissEvent); + }); + + let db = WorkspaceDb::global(cx); + cx.spawn_in(window, async move |this, cx| { + let workspaces = db + .recent_workspaces_on_disk(fs.as_ref()) + .await + .log_err() + .unwrap_or_default(); + let workspaces = resolve_worktree_workspaces(workspaces, fs.as_ref()).await; + this.update_in(cx, move |this, window, cx| { + this.picker.update(cx, move |picker, cx| { + picker.delegate.workspaces = workspaces; + picker.update_matches(picker.query(cx), window, cx) + }) + }) + .ok(); + }) + .detach(); + + picker.focus_handle(cx).focus(window, cx); + + Self { + picker, + _subscription, + } + } +} + +impl EventEmitter for ProjectPickerModal {} + +impl Focusable for ProjectPickerModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl ModalView for ProjectPickerModal {} + +impl Render for ProjectPickerModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("ProjectPickerModal") + .elevation_3(cx) + .w(rems(34.)) + .on_action(cx.listener(|this, _: &workspace::Open, window, cx| { + this.picker.update(cx, |picker, cx| { + picker.delegate.open_local_folder(window, cx) + }) + })) + .child(self.picker.clone()) + } +} + +enum ProjectPickerEntry { + Header(SharedString), + Workspace(StringMatch), +} + +struct ProjectPickerDelegate { + thread: ThreadMetadata, + archive_view: WeakEntity, + current_workspace_id: Option, + sibling_workspace_ids: HashSet, + workspaces: Vec<( + WorkspaceId, + SerializedWorkspaceLocation, + PathList, + DateTime, + )>, + filtered_entries: Vec, + selected_index: usize, + focus_handle: FocusHandle, +} + +impl ProjectPickerDelegate { + fn update_working_directories_and_unarchive( + &mut self, + paths: PathList, + window: &mut Window, + cx: &mut Context>, + ) { + self.thread.folder_paths = paths.clone(); + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.update_working_directories(&self.thread.session_id, paths, cx); + }); + + self.archive_view + .update(cx, |view, cx| { + view.selection = None; + view.reset_filter_editor_text(window, cx); + cx.emit(ThreadsArchiveViewEvent::Unarchive { + thread: self.thread.clone(), + }); + }) + .log_err(); + } + + fn is_current_workspace(&self, workspace_id: WorkspaceId) -> bool { + self.current_workspace_id == Some(workspace_id) + } + + fn is_sibling_workspace(&self, workspace_id: WorkspaceId) -> bool { + self.sibling_workspace_ids.contains(&workspace_id) + && !self.is_current_workspace(workspace_id) + } + + fn selected_match(&self) -> Option<&StringMatch> { + match self.filtered_entries.get(self.selected_index)? { + ProjectPickerEntry::Workspace(hit) => Some(hit), + ProjectPickerEntry::Header(_) => None, + } + } + + fn open_local_folder(&mut self, window: &mut Window, cx: &mut Context>) { + let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + cx.spawn_in(window, async move |this, cx| { + let Ok(Ok(Some(paths))) = paths_receiver.await else { + return; + }; + if paths.is_empty() { + return; + } + + let work_dirs = PathList::new(&paths); + + this.update_in(cx, |this, window, cx| { + this.delegate + .update_working_directories_and_unarchive(work_dirs, window, cx); + cx.emit(DismissEvent); + }) + .log_err(); + }) + .detach(); + } +} + +impl EventEmitter for ProjectPickerDelegate {} + +impl PickerDelegate for ProjectPickerDelegate { + type ListItem = AnyElement; + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + format!("Associate the \"{}\" thread with...", self.thread.title).into() + } + + fn render_editor( + &self, + editor: &Arc, + window: &mut Window, + cx: &mut Context>, + ) -> Div { + h_flex() + .flex_none() + .h_9() + .px_2p5() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(editor.render(window, cx)) + } + + fn match_count(&self) -> usize { + self.filtered_entries.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + matches!( + self.filtered_entries.get(ix), + Some(ProjectPickerEntry::Workspace(_)) + ) + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let query = query.trim_start(); + let smart_case = query.chars().any(|c| c.is_uppercase()); + let is_empty_query = query.is_empty(); + + let sibling_candidates: Vec<_> = self + .workspaces + .iter() + .enumerate() + .filter(|(_, (id, _, _, _))| self.is_sibling_workspace(*id)) + .map(|(id, (_, _, paths, _))| { + let combined_string = paths + .ordered_paths() + .map(|path| path.compact().to_string_lossy().into_owned()) + .collect::>() + .join(""); + StringMatchCandidate::new(id, &combined_string) + }) + .collect(); + + let mut sibling_matches = smol::block_on(fuzzy::match_strings( + &sibling_candidates, + query, + smart_case, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + )); + + sibling_matches.sort_unstable_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.candidate_id.cmp(&b.candidate_id)) + }); + + let recent_candidates: Vec<_> = self + .workspaces + .iter() + .enumerate() + .filter(|(_, (id, _, _, _))| { + !self.is_current_workspace(*id) && !self.is_sibling_workspace(*id) + }) + .map(|(id, (_, _, paths, _))| { + let combined_string = paths + .ordered_paths() + .map(|path| path.compact().to_string_lossy().into_owned()) + .collect::>() + .join(""); + StringMatchCandidate::new(id, &combined_string) + }) + .collect(); + + let mut recent_matches = smol::block_on(fuzzy::match_strings( + &recent_candidates, + query, + smart_case, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + )); + + recent_matches.sort_unstable_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.candidate_id.cmp(&b.candidate_id)) + }); + + let mut entries = Vec::new(); + + let has_siblings_to_show = if is_empty_query { + !sibling_candidates.is_empty() + } else { + !sibling_matches.is_empty() + }; + + if has_siblings_to_show { + entries.push(ProjectPickerEntry::Header("This Window".into())); + + if is_empty_query { + for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() { + if self.is_sibling_workspace(*workspace_id) { + entries.push(ProjectPickerEntry::Workspace(StringMatch { + candidate_id: id, + score: 0.0, + positions: Vec::new(), + string: String::new(), + })); + } + } + } else { + for m in sibling_matches { + entries.push(ProjectPickerEntry::Workspace(m)); + } + } + } + + let has_recent_to_show = if is_empty_query { + !recent_candidates.is_empty() + } else { + !recent_matches.is_empty() + }; + + if has_recent_to_show { + entries.push(ProjectPickerEntry::Header("Recent Projects".into())); + + if is_empty_query { + for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() { + if !self.is_current_workspace(*workspace_id) + && !self.is_sibling_workspace(*workspace_id) + { + entries.push(ProjectPickerEntry::Workspace(StringMatch { + candidate_id: id, + score: 0.0, + positions: Vec::new(), + string: String::new(), + })); + } + } + } else { + for m in recent_matches { + entries.push(ProjectPickerEntry::Workspace(m)); + } + } + } + + self.filtered_entries = entries; + + self.selected_index = self + .filtered_entries + .iter() + .position(|e| matches!(e, ProjectPickerEntry::Workspace(_))) + .unwrap_or(0); + + Task::ready(()) + } + + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + let candidate_id = match self.filtered_entries.get(self.selected_index) { + Some(ProjectPickerEntry::Workspace(hit)) => hit.candidate_id, + _ => return, + }; + let Some((_workspace_id, _location, paths, _)) = self.workspaces.get(candidate_id) else { + return; + }; + + self.update_working_directories_and_unarchive(paths.clone(), window, cx); + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + let text = if self.workspaces.is_empty() { + "No recent projects found" + } else { + "No matches" + }; + Some(text.into()) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + match self.filtered_entries.get(ix)? { + ProjectPickerEntry::Header(title) => Some( + v_flex() + .w_full() + .gap_1() + .when(ix > 0, |this| this.mt_1().child(Divider::horizontal())) + .child(ListSubHeader::new(title.clone()).inset(true)) + .into_any_element(), + ), + ProjectPickerEntry::Workspace(hit) => { + let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?; + + let ordered_paths: Vec<_> = paths + .ordered_paths() + .map(|p| p.compact().to_string_lossy().to_string()) + .collect(); + + let tooltip_path: SharedString = ordered_paths.join("\n").into(); + + let mut path_start_offset = 0; + let match_labels: Vec<_> = paths + .ordered_paths() + .map(|p| p.compact()) + .map(|path| { + let path_string = path.to_string_lossy(); + let path_text = path_string.to_string(); + let path_byte_len = path_text.len(); + + let path_positions: Vec = hit + .positions + .iter() + .copied() + .skip_while(|pos| *pos < path_start_offset) + .take_while(|pos| *pos < path_start_offset + path_byte_len) + .map(|pos| pos - path_start_offset) + .collect(); + + let file_name_match = path.file_name().map(|file_name| { + let file_name_text = file_name.to_string_lossy().into_owned(); + let file_name_start = path_byte_len - file_name_text.len(); + let highlight_positions: Vec = path_positions + .iter() + .copied() + .skip_while(|pos| *pos < file_name_start) + .take_while(|pos| *pos < file_name_start + file_name_text.len()) + .map(|pos| pos - file_name_start) + .collect(); + HighlightedMatch { + text: file_name_text, + highlight_positions, + color: Color::Default, + } + }); + + path_start_offset += path_byte_len; + file_name_match + }) + .collect(); + + let highlighted_match = HighlightedMatchWithPaths { + prefix: match location { + SerializedWorkspaceLocation::Remote(options) => { + Some(SharedString::from(options.display_name())) + } + _ => None, + }, + match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "), + paths: Vec::new(), + }; + + Some( + ListItem::new(ix) + .toggle_state(selected) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child( + h_flex() + .gap_3() + .flex_grow() + .child(highlighted_match.render(window, cx)), + ) + .tooltip(Tooltip::text(tooltip_path)) + .into_any_element(), + ) + } + } + } + + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + let has_selection = self.selected_match().is_some(); + let focus_handle = self.focus_handle.clone(); + + Some( + h_flex() + .flex_1() + .p_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("open_local_folder", "Choose from Local Folders") + .key_binding(KeyBinding::for_action_in( + &workspace::Open::default(), + &focus_handle, + cx, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.open_local_folder(window, cx); + })), + ) + .child( + Button::new("select_project", "Select") + .disabled(!has_selection) + .key_binding(KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)) + .on_click(cx.listener(move |picker, _, window, cx| { + picker.delegate.confirm(false, window, cx); + })), + ) + .into_any(), + ) + } +} diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index b1257b4c79c2ef193ec4594139cd1f57b93a5666..e09ee3e8809417924b1b1b43f25cee75834568a1 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -3488,6 +3488,7 @@ impl Sidebar { let archive_view = cx.new(|cx| { ThreadsArchiveView::new( + active_workspace.downgrade(), agent_connection_store.clone(), agent_server_store.clone(), window, From c5446117c19e53a8627e0d0dae4c63b69a86b076 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Wed, 1 Apr 2026 17:05:03 +0800 Subject: [PATCH 15/22] markdown: Refactor code block copy button visibility to use enum (#52817) Release Notes: - N/A --------- Signed-off-by: Xiaobo Liu Co-authored-by: Finn Evers Co-authored-by: MrSubidubi --- crates/acp_tools/src/acp_tools.rs | 9 ++- crates/diagnostics/src/diagnostic_renderer.rs | 5 +- crates/editor/src/code_context_menus.rs | 5 +- crates/editor/src/hover_popover.rs | 8 +-- crates/editor/src/signature_help.rs | 8 +-- crates/markdown/src/html/html_rendering.rs | 11 ++-- crates/markdown/src/markdown.rs | 61 +++++++------------ crates/markdown/src/mermaid.rs | 11 ++-- .../src/markdown_preview_view.rs | 6 +- crates/workspace/src/notifications.rs | 5 +- crates/zed/src/zed/telemetry_log.rs | 9 ++- 11 files changed, 61 insertions(+), 77 deletions(-) diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 52a9d03f893d0b82bf6395b4c96bc9ebe14d3afe..ae8a39c8df4f73ae8be6b748694dbde5d2a0c102 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -13,7 +13,7 @@ use gpui::{ StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, prelude::*, }; use language::LanguageRegistry; -use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; +use markdown::{CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle}; use project::{AgentId, Project}; use settings::Settings; use theme_settings::ThemeSettings; @@ -384,8 +384,11 @@ impl AcpTools { ) .code_block_renderer( CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: expanded, + copy_button_visibility: if expanded { + CopyButtonVisibility::VisibleOnHover + } else { + CopyButtonVisibility::Hidden + }, border: false, }, ), diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 27e1cbbac9c779056ecd9da00dd7a56ff3536f17..62b7f4eadf322da1c57a9f1da60b412d7b0dcd68 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -8,7 +8,7 @@ use editor::{ use gpui::{AppContext, Entity, Focusable, WeakEntity}; use language::{BufferId, Diagnostic, DiagnosticEntryRef, LanguageRegistry}; use lsp::DiagnosticSeverity; -use markdown::{Markdown, MarkdownElement}; +use markdown::{CopyButtonVisibility, Markdown, MarkdownElement}; use settings::Settings; use text::{AnchorRangeExt, Point}; use theme_settings::ThemeSettings; @@ -239,8 +239,7 @@ impl DiagnosticBlock { diagnostics_markdown_style(bcx.window, cx), ) .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }) .on_url_click({ diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 3fc6080b4da8ca85d258d04de29d603ea7097623..5d6c037d9b67034423dda9f119a1e78fb1e5b9b2 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -9,7 +9,7 @@ use itertools::Itertools; use language::CodeLabel; use language::{Buffer, LanguageName, LanguageRegistry}; use lsp::CompletionItemTag; -use markdown::{Markdown, MarkdownElement}; +use markdown::{CopyButtonVisibility, Markdown, MarkdownElement}; use multi_buffer::{Anchor, ExcerptId}; use ordered_float::OrderedFloat; use project::lsp_store::CompletionDocumentation; @@ -1118,8 +1118,7 @@ impl CompletionsMenu { div().child( MarkdownElement::new(markdown, hover_markdown_style(window, cx)) .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }) .on_url_click(open_markdown_url), diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 9b127a8f1bc089d9cee28254c6b8ffc181677765..3bad6c97b6bcba4015331257a5b9a476dd0d3fd3 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -17,7 +17,7 @@ use gpui::{ use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; use lsp::DiagnosticSeverity; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle}; use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart}; use settings::Settings; @@ -1040,8 +1040,7 @@ impl InfoPopover { .child( MarkdownElement::new(markdown, hover_markdown_style(window, cx)) .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }) .on_url_click(open_markdown_url) @@ -1155,8 +1154,7 @@ impl DiagnosticPopover { diagnostics_markdown_style(window, cx), ) .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }) .on_url_click( diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index 27c26d4691686c16bcbafbf74bba6b5f1156b835..6305fc73e44d745e943c1d4c8ec573e0cce7d9ed 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -7,7 +7,7 @@ use gpui::{ }; use language::BufferSnapshot; -use markdown::{Markdown, MarkdownElement}; +use markdown::{CopyButtonVisibility, Markdown, MarkdownElement}; use multi_buffer::{Anchor, MultiBufferOffset, ToOffset}; use settings::Settings; use std::ops::Range; @@ -408,9 +408,8 @@ impl SignatureHelpPopover { hover_markdown_style(window, cx), ) .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, - copy_button_on_hover: false, }) .on_url_click(open_markdown_url), ) @@ -421,9 +420,8 @@ impl SignatureHelpPopover { .child( MarkdownElement::new(description, hover_markdown_style(window, cx)) .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, - copy_button_on_hover: false, }) .on_url_click(open_markdown_url), ) diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 56ab2db26b682e197c194157a87e646d9e55019d..103e2a6accb7dce9bc429419aafd27cbdf5080ce 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -497,7 +497,10 @@ mod tests { use gpui::{TestAppContext, size}; use ui::prelude::*; - use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle}; + use crate::{ + CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions, + MarkdownStyle, + }; fn ensure_theme_initialized(cx: &mut TestAppContext) { cx.update(|cx| { @@ -530,8 +533,7 @@ mod tests { |_window, _cx| { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }, ) @@ -591,8 +593,7 @@ mod tests { |_window, _cx| { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }, ) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 6063e98229025d4160b9d3aeb4b412494f443e7d..c31ca79e7581926e7696fa596aaccc9371512841 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -270,10 +270,16 @@ pub struct MarkdownOptions { pub render_mermaid_diagrams: bool, } +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum CopyButtonVisibility { + Hidden, + AlwaysVisible, + VisibleOnHover, +} + pub enum CodeBlockRenderer { Default { - copy_button: bool, - copy_button_on_hover: bool, + copy_button_visibility: CopyButtonVisibility, border: bool, }, Custom { @@ -826,8 +832,7 @@ impl MarkdownElement { markdown, style, code_block_renderer: CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: true, + copy_button_visibility: CopyButtonVisibility::VisibleOnHover, border: false, }, on_url_click: None, @@ -1686,38 +1691,10 @@ impl Element for MarkdownElement { builder.pop_text_style(); if let CodeBlockRenderer::Default { - copy_button: true, .. - } = &self.code_block_renderer - { - builder.modify_current_div(|el| { - let content_range = parser::extract_code_block_content_range( - &parsed_markdown.source()[range.clone()], - ); - let content_range = content_range.start + range.start - ..content_range.end + range.start; - - let code = parsed_markdown.source()[content_range].to_string(); - let codeblock = render_copy_code_block_button( - range.end, - code, - self.markdown.clone(), - ); - el.child( - h_flex() - .w_4() - .absolute() - .top_1p5() - .right_1p5() - .justify_end() - .child(codeblock), - ) - }); - } - - if let CodeBlockRenderer::Default { - copy_button_on_hover: true, + copy_button_visibility, .. } = &self.code_block_renderer + && *copy_button_visibility != CopyButtonVisibility::Hidden { builder.modify_current_div(|el| { let content_range = parser::extract_code_block_content_range( @@ -1736,10 +1713,17 @@ impl Element for MarkdownElement { h_flex() .w_4() .absolute() - .top_0() - .right_0() .justify_end() - .visible_on_hover("code_block") + .when_else( + *copy_button_visibility + == CopyButtonVisibility::VisibleOnHover, + |this| { + this.top_0() + .right_0() + .visible_on_hover("code_block") + }, + |this| this.top_1p5().right_1p5(), + ) .child(codeblock), ) }); @@ -2772,8 +2756,7 @@ mod tests { |_window, _cx| { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }, ) diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 15f3de4d8e8c64010fe96846b05d75f012c5fc0d..b8e40ebe7ec16cbbb8d9b11ab3edfc75da46f3a9 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -266,7 +266,10 @@ mod tests { CachedMermaidDiagram, MermaidDiagramCache, MermaidState, ParsedMarkdownMermaidDiagramContents, extract_mermaid_diagrams, parse_mermaid_info, }; - use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle}; + use crate::{ + CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions, + MarkdownStyle, + }; use collections::HashMap; use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size}; use std::sync::Arc; @@ -309,8 +312,7 @@ mod tests { |_window, _cx| { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }, ) @@ -581,8 +583,7 @@ mod tests { |_window, _cx| { MarkdownElement::new(markdown.clone(), MarkdownStyle::default()) .code_block_renderer(CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }) }, diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 8e289e451dada6170f7b2bd7282ef9f165d26cff..6dbf44c20f3ce453a7ef711e1854b806cf29737a 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -13,7 +13,8 @@ use gpui::{ }; use language::LanguageRegistry; use markdown::{ - CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, + CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, + MarkdownOptions, MarkdownStyle, }; use settings::Settings; use theme_settings::ThemeSettings; @@ -593,8 +594,7 @@ impl MarkdownPreviewView { MarkdownStyle::themed(MarkdownFont::Editor, window, cx), ) .code_block_renderer(CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: true, + copy_button_visibility: CopyButtonVisibility::VisibleOnHover, border: false, }) .scroll_handle(self.scroll_handle.clone()) diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index dbf2accf3dd9910426ca3557daf9cee0e5b0a82b..b4f683fa6952b9d6f26b8933e010f4c7d2de898c 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -5,7 +5,7 @@ use gpui::{ DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, Task, TextStyleRefinement, UnderlineStyle, WeakEntity, svg, }; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::project_settings::ProjectSettings; use settings::Settings; @@ -401,8 +401,7 @@ impl Render for LanguageServerPrompt { MarkdownElement::new(self.markdown.clone(), markdown_style(window, cx)) .text_size(TextSize::Small.rems(cx)) .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, + copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }) .on_url_click(|link, _, cx| cx.open_url(&link)), diff --git a/crates/zed/src/zed/telemetry_log.rs b/crates/zed/src/zed/telemetry_log.rs index cc07783f57b27cc57a281089effb208fc3947050..7df7e83d25804edb1a7a73abf055d9adaf080a90 100644 --- a/crates/zed/src/zed/telemetry_log.rs +++ b/crates/zed/src/zed/telemetry_log.rs @@ -12,7 +12,7 @@ use gpui::{ StyleRefinement, Task, TextStyleRefinement, Window, list, prelude::*, }; use language::LanguageRegistry; -use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; +use markdown::{CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle}; use project::Project; use settings::Settings; use telemetry_events::{Event, EventWrapper}; @@ -424,8 +424,11 @@ impl TelemetryLogView { }, ) .code_block_renderer(CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: expanded, + copy_button_visibility: if expanded { + CopyButtonVisibility::VisibleOnHover + } else { + CopyButtonVisibility::Hidden + }, border: false, }), ), From 02e8914fe0979f95fe0bd779dfc07de9d8027d52 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 1 Apr 2026 11:47:45 +0200 Subject: [PATCH 16/22] agent_ui: Use selected agent for new threads (#52888) Persist the last used agent globally as a fallback for new workspaces, keep per-workspace selections independent. This should mean "new thread" should grab whatever agent you are currently looking at, and won't leak across projects. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - agent: Prefer the currently used agent per-project when creating a new thread. Co-authored-by: Bennet Bo Fenner Co-authored-by: MrSubidubi --- crates/agent_ui/src/agent_panel.rs | 386 ++++++++++++++++++++++------- 1 file changed, 295 insertions(+), 91 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index a32f92942682fc0c5efbbcd35a9848c90b761184..a85cb86de4b71c8fc70783b643b13087eeb4d22f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -86,6 +86,30 @@ use zed_actions::{ const AGENT_PANEL_KEY: &str = "agent_panel"; const RECENTLY_UPDATED_MENU_LIMIT: usize = 6; +const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; + +#[derive(Serialize, Deserialize)] +struct LastUsedAgent { + agent: Agent, +} + +/// Reads the most recently used agent across all workspaces. Used as a fallback +/// when opening a workspace that has no per-workspace agent preference yet. +fn read_global_last_used_agent(kvp: &KeyValueStore) -> Option { + kvp.read_kvp(LAST_USED_AGENT_KEY) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) + .map(|entry| entry.agent) +} + +async fn write_global_last_used_agent(kvp: KeyValueStore, agent: Agent) { + if let Some(json) = serde_json::to_string(&LastUsedAgent { agent }).log_err() { + kvp.write_kvp(LAST_USED_AGENT_KEY.to_string(), json) + .await + .log_err(); + } +} fn read_serialized_panel( workspace_id: workspace::WorkspaceId, @@ -665,13 +689,18 @@ impl AgentPanel { .ok() .flatten(); - let serialized_panel = cx + let (serialized_panel, global_last_used_agent) = cx .background_spawn(async move { - kvp.and_then(|kvp| { - workspace_id - .and_then(|id| read_serialized_panel(id, &kvp)) - .or_else(|| read_legacy_serialized_panel(&kvp)) - }) + match kvp { + Some(kvp) => { + let panel = workspace_id + .and_then(|id| read_serialized_panel(id, &kvp)) + .or_else(|| read_legacy_serialized_panel(&kvp)); + let global_agent = read_global_last_used_agent(&kvp); + (panel, global_agent) + } + None => (None, None), + } }) .await; @@ -710,10 +739,21 @@ impl AgentPanel { let panel = cx.new(|cx| Self::new(workspace, prompt_store, window, cx)); - if let Some(serialized_panel) = &serialized_panel { - panel.update(cx, |panel, cx| { + panel.update(cx, |panel, cx| { + let is_via_collab = panel.project.read(cx).is_via_collab(); + + // Only apply a non-native global fallback to local projects. + // Collab workspaces only support NativeAgent, so inheriting a + // custom agent would cause set_active → new_agent_thread_inner + // to bypass the collab guard in external_thread. + let global_fallback = global_last_used_agent + .filter(|agent| !is_via_collab || agent.is_native()); + + if let Some(serialized_panel) = &serialized_panel { if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent = selected_agent; + } else if let Some(agent) = global_fallback { + panel.selected_agent = agent; } if let Some(start_thread_in) = serialized_panel.start_thread_in { let is_worktree_flag_enabled = @@ -734,9 +774,11 @@ impl AgentPanel { ); } } - cx.notify(); - }); - } + } else if let Some(agent) = global_fallback { + panel.selected_agent = agent; + } + cx.notify(); + }); if let Some(thread_info) = last_active_thread { let agent = thread_info.agent_type.clone(); @@ -1069,85 +1111,30 @@ impl AgentPanel { let workspace = self.workspace.clone(); let project = self.project.clone(); let fs = self.fs.clone(); - let is_via_collab = self.project.read(cx).is_via_collab(); - - const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; - - #[derive(Serialize, Deserialize)] - struct LastUsedExternalAgent { - agent: crate::Agent, - } - let thread_store = self.thread_store.clone(); - let kvp = KeyValueStore::global(cx); - - if let Some(agent) = agent_choice { - cx.background_spawn({ - let agent = agent.clone(); - let kvp = kvp; - async move { - if let Some(serialized) = - serde_json::to_string(&LastUsedExternalAgent { agent }).log_err() - { - kvp.write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized) - .await - .log_err(); - } - } - }) - .detach(); - - let server = agent.server(fs, thread_store); - self.create_agent_thread( - server, - resume_session_id, - work_dirs, - title, - initial_content, - workspace, - project, - agent, - focus, - window, - cx, - ); - } else { - cx.spawn_in(window, async move |this, cx| { - let ext_agent = if is_via_collab { - Agent::NativeAgent - } else { - cx.background_spawn(async move { kvp.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) }) - .await - .log_err() - .flatten() - .and_then(|value| { - serde_json::from_str::(&value).log_err() - }) - .map(|agent| agent.agent) - .unwrap_or(Agent::NativeAgent) - }; - let server = ext_agent.server(fs, thread_store); - this.update_in(cx, |agent_panel, window, cx| { - agent_panel.create_agent_thread( - server, - resume_session_id, - work_dirs, - title, - initial_content, - workspace, - project, - ext_agent, - focus, - window, - cx, - ); - })?; + let agent = agent_choice.unwrap_or_else(|| { + if self.project.read(cx).is_via_collab() { + Agent::NativeAgent + } else { + self.selected_agent.clone() + } + }); - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } + let server = agent.server(fs, thread_store); + self.create_agent_thread( + server, + resume_session_id, + work_dirs, + title, + initial_content, + workspace, + project, + agent, + focus, + window, + cx, + ); } fn deploy_rules_library( @@ -2102,15 +2089,25 @@ impl AgentPanel { initial_content: Option, workspace: WeakEntity, project: Entity, - ext_agent: Agent, + agent: Agent, focus: bool, window: &mut Window, cx: &mut Context, ) { - if self.selected_agent != ext_agent { - self.selected_agent = ext_agent.clone(); + if self.selected_agent != agent { + self.selected_agent = agent.clone(); self.serialize(cx); } + + cx.background_spawn({ + let kvp = KeyValueStore::global(cx); + let agent = agent.clone(); + async move { + write_global_last_used_agent(kvp, agent).await; + } + }) + .detach(); + let thread_store = server .clone() .downcast::() @@ -2123,7 +2120,7 @@ impl AgentPanel { crate::ConversationView::new( server, connection_store, - ext_agent, + agent, resume_session_id, work_dirs, title, @@ -5611,4 +5608,211 @@ mod tests { "Thread A work_dirs should revert to only /project_a after removing /project_b" ); } + + #[gpui::test] + async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + // Use an isolated DB so parallel tests can't overwrite our global key. + cx.set_global(db::AppDatabase::test_new()); + }); + + let custom_agent = Agent::Custom { + id: "my-preferred-agent".into(), + }; + + // Write a known agent to the global KVP to simulate a user who has + // previously used this agent in another workspace. + let kvp = cx.update(|cx| KeyValueStore::global(cx)); + write_global_last_used_agent(kvp, custom_agent.clone()).await; + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Load the panel via `load()`, which reads the global fallback + // asynchronously when no per-workspace state exists. + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let panel = AgentPanel::load(workspace.downgrade(), async_cx) + .await + .expect("panel load should succeed"); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert_eq!( + panel.selected_agent, custom_agent, + "new workspace should inherit the global last-used agent" + ); + }); + } + + #[gpui::test] + async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs, [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + workspace_a.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + workspace_b.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let agent_a = Agent::Custom { + id: "agent-alpha".into(), + }; + let agent_b = Agent::Custom { + id: "agent-beta".into(), + }; + + // Set up workspace A with agent_a + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + }); + panel_a.update(cx, |panel, _cx| { + panel.selected_agent = agent_a.clone(); + }); + + // Set up workspace B with agent_b + let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { + cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + }); + panel_b.update(cx, |panel, _cx| { + panel.selected_agent = agent_b.clone(); + }); + + // Serialize both panels + panel_a.update(cx, |panel, cx| panel.serialize(cx)); + panel_b.update(cx, |panel, cx| panel.serialize(cx)); + cx.run_until_parked(); + + // Load fresh panels from serialized state and verify independence + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx) + .await + .expect("panel A load should succeed"); + cx.run_until_parked(); + + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx) + .await + .expect("panel B load should succeed"); + cx.run_until_parked(); + + loaded_a.read_with(cx, |panel, _cx| { + assert_eq!( + panel.selected_agent, agent_a, + "workspace A should restore agent-alpha, not agent-beta" + ); + }); + + loaded_b.read_with(cx, |panel, _cx| { + assert_eq!( + panel.selected_agent, agent_b, + "workspace B should restore agent-beta, not agent-alpha" + ); + }); + } + + #[gpui::test] + async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let custom_agent = Agent::Custom { + id: "my-custom-agent".into(), + }; + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + // Set selected_agent to a custom agent + panel.update(cx, |panel, _cx| { + panel.selected_agent = custom_agent.clone(); + }); + + // Call new_thread, which internally calls external_thread(None, ...) + // This resolves the agent from self.selected_agent + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + + panel.read_with(cx, |panel, _cx| { + assert_eq!( + panel.selected_agent, custom_agent, + "selected_agent should remain the custom agent after new_thread" + ); + assert!( + panel.active_conversation_view().is_some(), + "a thread should have been created" + ); + }); + } } From 224ce68200c3adace1f0bb94f7f5af00ac6dfe5a Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 1 Apr 2026 11:59:07 +0200 Subject: [PATCH 17/22] migrator: Remove text thread settings migration (#52889) Since this was just removing unused keys, but behavior isn't broken if they are there. So we can just leave them as-is Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A Co-authored-by: Bennet Bo Fenner Co-authored-by: MrSubidubi --- crates/migrator/src/migrations.rs | 6 - .../src/migrations/m_2026_03_31/settings.rs | 29 ----- crates/migrator/src/migrator.rs | 109 +----------------- 3 files changed, 2 insertions(+), 142 deletions(-) delete mode 100644 crates/migrator/src/migrations/m_2026_03_31/settings.rs diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index c49df39d59abaa924edb6c986c63701952dce01e..d554ee1dd887d6048f55a584ed2534db944b3c08 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -316,9 +316,3 @@ pub(crate) mod m_2026_03_23 { pub(crate) use keymap::KEYMAP_PATTERNS; } - -pub(crate) mod m_2026_03_31 { - mod settings; - - pub(crate) use settings::remove_text_thread_settings; -} diff --git a/crates/migrator/src/migrations/m_2026_03_31/settings.rs b/crates/migrator/src/migrations/m_2026_03_31/settings.rs deleted file mode 100644 index 1a3fdb109f3773bada7a5fd5c00b1947e556e4c9..0000000000000000000000000000000000000000 --- a/crates/migrator/src/migrations/m_2026_03_31/settings.rs +++ /dev/null @@ -1,29 +0,0 @@ -use anyhow::Result; -use serde_json::Value; - -use crate::migrations::migrate_settings; - -pub fn remove_text_thread_settings(value: &mut Value) -> Result<()> { - migrate_settings(value, &mut migrate_one) -} - -fn migrate_one(obj: &mut serde_json::Map) -> Result<()> { - // Remove `agent.default_view` - if let Some(agent) = obj.get_mut("agent") { - if let Some(agent_obj) = agent.as_object_mut() { - agent_obj.remove("default_view"); - } - } - - // Remove `edit_predictions.enabled_in_text_threads` - if let Some(edit_predictions) = obj.get_mut("edit_predictions") { - if let Some(edit_predictions_obj) = edit_predictions.as_object_mut() { - edit_predictions_obj.remove("enabled_in_text_threads"); - } - } - - // Remove top-level `slash_commands` - obj.remove("slash_commands"); - - Ok(()) -} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 46cccfc4055a78a27d12da54ee187a0fdc202917..ceb6ec2e0e35f0dd3bbd23174637bba00baab6b3 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -247,7 +247,6 @@ pub fn migrate_settings(text: &str) -> Result> { migrations::m_2026_03_16::SETTINGS_PATTERNS, &SETTINGS_QUERY_2026_03_16, ), - MigrationType::Json(migrations::m_2026_03_31::remove_text_thread_settings), ]; run_migrations(text, migrations) } @@ -941,7 +940,8 @@ mod tests { "foo": "bar" }, "edit_predictions": { - } + "enabled_in_text_threads": false, + } }"#, ), ); @@ -4480,109 +4480,4 @@ mod tests { ), ); } - - #[test] - fn test_remove_text_thread_settings() { - assert_migrate_with_migrations( - &[MigrationType::Json( - migrations::m_2026_03_31::remove_text_thread_settings, - )], - r#"{ - "agent": { - "default_model": { - "provider": "anthropic", - "model": "claude-sonnet" - }, - "default_view": "text_thread" - }, - "edit_predictions": { - "mode": "eager", - "enabled_in_text_threads": true - }, - "slash_commands": { - "cargo_workspace": { - "enabled": true - } - } -}"#, - Some( - r#"{ - "agent": { - "default_model": { - "provider": "anthropic", - "model": "claude-sonnet" - } - }, - "edit_predictions": { - "mode": "eager" - } -}"#, - ), - ); - } - - #[test] - fn test_remove_text_thread_settings_only_default_view() { - assert_migrate_with_migrations( - &[MigrationType::Json( - migrations::m_2026_03_31::remove_text_thread_settings, - )], - r#"{ - "agent": { - "default_model": "claude-sonnet", - "default_view": "thread" - } -}"#, - Some( - r#"{ - "agent": { - "default_model": "claude-sonnet" - } -}"#, - ), - ); - } - - #[test] - fn test_remove_text_thread_settings_only_slash_commands() { - assert_migrate_with_migrations( - &[MigrationType::Json( - migrations::m_2026_03_31::remove_text_thread_settings, - )], - r#"{ - "slash_commands": { - "cargo_workspace": { - "enabled": true - } - }, - "vim_mode": true -}"#, - Some( - r#"{ - "vim_mode": true -}"#, - ), - ); - } - - #[test] - fn test_remove_text_thread_settings_none_present() { - assert_migrate_with_migrations( - &[MigrationType::Json( - migrations::m_2026_03_31::remove_text_thread_settings, - )], - r#"{ - "agent": { - "default_model": { - "provider": "anthropic", - "model": "claude-sonnet" - } - }, - "edit_predictions": { - "mode": "eager" - } -}"#, - None, - ); - } } From 66f9e328fcd487d4478152a54d30f35fe85650ba Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 1 Apr 2026 14:47:55 +0200 Subject: [PATCH 18/22] sidebar: Archive threads without a project association automatically (#52897) --- crates/agent_ui/src/thread_metadata_store.rs | 86 +++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index a8b531eb59e7aab740678c464e21e4b54daa3f59..4c66d57bcfafe98432319a173e7736a581f1d986 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -517,7 +517,13 @@ impl ThreadMetadataStore { PathList::new(&paths) }; - let archived = existing_thread.map(|t| t.archived).unwrap_or(false); + // Threads without a folder path (e.g. started in an empty + // window) are archived by default so they don't get lost, + // because they won't show up in the sidebar. Users can reload + // them from the archive. + let archived = existing_thread + .map(|t| t.archived) + .unwrap_or(folder_paths.is_empty()); let metadata = ThreadMetadata { session_id, @@ -1286,6 +1292,84 @@ mod tests { assert_eq!(metadata_ids, vec![session_id]); } + #[gpui::test] + async fn test_threads_without_project_association_are_archived_by_default( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project_without_worktree = Project::test(fs.clone(), None::<&Path>, cx).await; + let project_with_worktree = Project::test(fs, [Path::new("/project-a")], cx).await; + let connection = Rc::new(StubAgentConnection::new()); + + let thread_without_worktree = cx + .update(|cx| { + connection.clone().new_session( + project_without_worktree.clone(), + PathList::default(), + cx, + ) + }) + .await + .unwrap(); + let session_without_worktree = + cx.read(|cx| thread_without_worktree.read(cx).session_id().clone()); + + cx.update(|cx| { + thread_without_worktree.update(cx, |thread, cx| { + thread.set_title("No Project Thread".into(), cx).detach(); + }); + }); + cx.run_until_parked(); + + let thread_with_worktree = cx + .update(|cx| { + connection.clone().new_session( + project_with_worktree.clone(), + PathList::default(), + cx, + ) + }) + .await + .unwrap(); + let session_with_worktree = + cx.read(|cx| thread_with_worktree.read(cx).session_id().clone()); + + cx.update(|cx| { + thread_with_worktree.update(cx, |thread, cx| { + thread.set_title("Project Thread".into(), cx).detach(); + }); + }); + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let without_worktree = store + .entry(&session_without_worktree) + .expect("missing metadata for thread without project association"); + assert!(without_worktree.folder_paths.is_empty()); + assert!( + without_worktree.archived, + "expected thread without project association to be archived" + ); + + let with_worktree = store + .entry(&session_with_worktree) + .expect("missing metadata for thread with project association"); + assert_eq!( + with_worktree.folder_paths, + PathList::new(&[Path::new("/project-a")]) + ); + assert!( + !with_worktree.archived, + "expected thread with project association to remain unarchived" + ); + }); + } + #[gpui::test] async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) { init_test(cx); From f5993d801bed50eefba134ba4c063db1c154d869 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:30:37 -0300 Subject: [PATCH 19/22] agent_ui: Add more refinements to the thinking block display (#52874) Follow-up to https://github.com/zed-industries/zed/pull/52608 This PR adds a new iteration to the thinking block display design after some internal round of feedback. It turns out, we had some people appreciating the auto-collapse when thinking is done; thinking content isn't too useful afterwards and it is just more content _to to he model_, not the user. I also liked the one old but it definitely has the issue of being a jarring layout shift when it wraps up. So that's why I'm keeping what I introduced in the PR linked above as a setting, so that anyone who feels strongly about the default (auto-expand, and auto-collapse) can change that. Release Notes: - N/A --- assets/settings/default.json | 4 +-- .../src/conversation_view/thread_view.rs | 33 ++++++++++++++++--- crates/settings_content/src/agent.rs | 7 ++-- crates/settings_ui/src/page_data.rs | 2 +- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 57bad245474b9469a0a9b9d5674c692059f039af..2e0ddc2da70af5516d14a2fa8418a759bec62eb1 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1117,8 +1117,8 @@ "expand_terminal_card": true, // How thinking blocks should be displayed by default in the agent panel. // - // Default: automatic - "thinking_display": "automatic", + // Default: auto + "thinking_display": "auto", // Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. // Note that this only applies to the stop button, not to ctrl+c inside the terminal. // diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 63aa8b8529655a26b99ba74062f8d0a6a4812c5f..b25769eadbe31c35a6261cc9433349a2943617be 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -5152,9 +5152,12 @@ impl ThreadView { } pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context) { - // Only auto-expand thinking blocks in Automatic mode. - // AlwaysExpanded shows them open by default; AlwaysCollapsed keeps them closed. - if AgentSettings::get_global(cx).thinking_display != ThinkingBlockDisplay::Automatic { + let thinking_display = AgentSettings::get_global(cx).thinking_display; + + if !matches!( + thinking_display, + ThinkingBlockDisplay::Auto | ThinkingBlockDisplay::Preview + ) { return; } @@ -5183,6 +5186,13 @@ impl ThreadView { cx.notify(); } } else if self.auto_expanded_thinking_block.is_some() { + if thinking_display == ThinkingBlockDisplay::Auto { + if let Some(key) = self.auto_expanded_thinking_block { + if !self.user_toggled_thinking_blocks.contains(&key) { + self.expanded_thinking_blocks.remove(&key); + } + } + } self.auto_expanded_thinking_block = None; cx.notify(); } @@ -5196,7 +5206,16 @@ impl ThreadView { let thinking_display = AgentSettings::get_global(cx).thinking_display; match thinking_display { - ThinkingBlockDisplay::Automatic => { + ThinkingBlockDisplay::Auto => { + if self.expanded_thinking_blocks.contains(&key) { + self.expanded_thinking_blocks.remove(&key); + self.user_toggled_thinking_blocks.insert(key); + } else { + self.expanded_thinking_blocks.insert(key); + self.user_toggled_thinking_blocks.insert(key); + } + } + ThinkingBlockDisplay::Preview => { let is_user_expanded = self.user_toggled_thinking_blocks.contains(&key); let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); @@ -5249,7 +5268,11 @@ impl ThreadView { let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); let (is_open, is_constrained) = match thinking_display { - ThinkingBlockDisplay::Automatic => { + ThinkingBlockDisplay::Auto => { + let is_open = is_user_toggled || is_in_expanded_set; + (is_open, false) + } + ThinkingBlockDisplay::Preview => { let is_open = is_user_toggled || is_in_expanded_set; let is_constrained = is_in_expanded_set && !is_user_toggled; (is_open, is_constrained) diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index f9d3376a26b8d84d89e563b21a969bfca68ee2f7..dae5c99b9ef9b5b3892b1201ff9a1686330dc365 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -81,11 +81,14 @@ pub enum SidebarSide { )] #[serde(rename_all = "snake_case")] pub enum ThinkingBlockDisplay { + /// Thinking blocks fully expand during streaming, then auto-collapse + /// when the model finishes thinking. Users can re-expand after collapse. + #[default] + Auto, /// Thinking blocks auto-expand with a height constraint during streaming, /// then remain in their constrained state when complete. Users can click /// to fully expand or collapse. - #[default] - Automatic, + Preview, /// Thinking blocks are always fully expanded by default (no height constraint). AlwaysExpanded, /// Thinking blocks are always collapsed by default. diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 08a597dc992913e144ba70e30c1a81b2ab8de1aa..b6d10424f4a6cf0710a916410e0e6068d80d6064 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7340,7 +7340,7 @@ fn ai_page(cx: &App) -> SettingsPage { }), SettingsPageItem::SettingItem(SettingItem { title: "Thinking Display", - description: "How thinking blocks should be displayed by default. 'Automatic' auto-expands with a height constraint during streaming. 'Always Expanded' shows full content. 'Always Collapsed' keeps them collapsed.", + description: "How thinking blocks should be displayed by default. 'Auto' fully expands during streaming, then auto-collapses when done. 'Preview' auto-expands with a height constraint during streaming. 'Always Expanded' shows full content. 'Always Collapsed' keeps them collapsed.", field: Box::new(SettingField { json_path: Some("agent.thinking_display"), pick: |settings_content| { From 06dbce41b3bc28d817b31699976de17f9530334f Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:30:50 -0300 Subject: [PATCH 20/22] agent_ui: Improve adding selection as context (#52860) This PR improves adding selection as context particularly for terminals, making them not depend on open buffers. It also now works adding selection from a terminal that's no in the panel but as a tab. Also, I'm removing a behavior introduced in https://github.com/zed-industries/zed/pull/48045 that turned out to be confusing, where the selection keybinding would add as context the content of the current line I'm focused on. I think we shouldn't do this given that a lot of times, particularly when adding a selection from a terminal, I'd also end up adding content from a buffer just because my cursor was previously in there, even without anything selected on it. Saw myself multiple times deleting the unwanted buffer context crease in this case. If the keybinding is about _selection_, we should only trigger it when there's something selected.ing not do anything if there isn't any selection. Release Notes: - Agent: Improved adding selection as context particularly for terminals, making them not depend on open buffers. --- crates/agent_ui/src/agent_panel.rs | 45 +++++++++++++ crates/agent_ui/src/completion_provider.rs | 73 ++++++++++++---------- 2 files changed, 85 insertions(+), 33 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index a85cb86de4b71c8fc70783b643b13087eeb4d22f..0ed0aeb78bf8889136a479ed2dac5caba633db55 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -66,7 +66,10 @@ use project::project_settings::ProjectSettings; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; +use settings::TerminalDockPosition; use settings::{Settings, update_settings_file}; +use terminal::terminal_settings::TerminalSettings; +use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide, @@ -423,6 +426,48 @@ pub fn init(cx: &mut App) { }) .register_action( |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| { + let active_editor = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)); + let has_editor_selection = active_editor.is_some_and(|editor| { + editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }) + }); + + let has_terminal_selection = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .is_some_and(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .last_content + .selection_text + .as_ref() + .is_some_and(|text| !text.is_empty()) + }); + + let has_terminal_panel_selection = + workspace.panel::(cx).is_some_and(|panel| { + let position = match TerminalSettings::get_global(cx).dock { + TerminalDockPosition::Left => DockPosition::Left, + TerminalDockPosition::Bottom => DockPosition::Bottom, + TerminalDockPosition::Right => DockPosition::Right, + }; + let dock_is_open = + workspace.dock_at_position(position).read(cx).is_open(); + dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty() + }); + + if !has_editor_selection + && !has_terminal_selection + && !has_terminal_panel_selection + { + return; + } + let Some(panel) = workspace.panel::(cx) else { return; }; diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index b6be6502b152847822a79bc8c486195345c0a195..6259269834b0add5b87fd9d397e17671d30adb9f 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -28,7 +28,7 @@ use prompt_store::{PromptStore, UserPromptId}; use rope::Point; use settings::{Settings, TerminalDockPosition}; use terminal::terminal_settings::TerminalSettings; -use terminal_view::terminal_panel::TerminalPanel; +use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::{Anchor, ToOffset as _, ToPoint as _}; use ui::IconName; use ui::prelude::*; @@ -562,8 +562,7 @@ impl PromptCompletionProvider { .collect(); // Collect terminal selections from all terminal views if the terminal panel is visible - let terminal_selections: Vec = - terminal_selections_if_panel_open(workspace, cx); + let terminal_selections: Vec = terminal_selections(workspace, cx); const EDITOR_PLACEHOLDER: &str = "selection "; const TERMINAL_PLACEHOLDER: &str = "terminal "; @@ -1198,7 +1197,7 @@ impl PromptCompletionProvider { }) }); - let has_terminal_selection = !terminal_selections_if_panel_open(workspace, cx).is_empty(); + let has_terminal_selection = !terminal_selections(workspace, cx).is_empty(); if has_editor_selection || has_terminal_selection { entries.push(PromptContextEntry::Action( @@ -2169,28 +2168,45 @@ fn build_code_label_for_path( label.build() } -/// Returns terminal selections from all terminal views if the terminal panel is open. -fn terminal_selections_if_panel_open(workspace: &Entity, cx: &App) -> Vec { - let Some(panel) = workspace.read(cx).panel::(cx) else { - return Vec::new(); - }; +fn terminal_selections(workspace: &Entity, cx: &App) -> Vec { + let mut selections = Vec::new(); - // Check if the dock containing this panel is open - let position = match TerminalSettings::get_global(cx).dock { - TerminalDockPosition::Left => DockPosition::Left, - TerminalDockPosition::Bottom => DockPosition::Bottom, - TerminalDockPosition::Right => DockPosition::Right, - }; - let dock_is_open = workspace + // Check if the active item is a terminal (in a panel or not) + if let Some(terminal_view) = workspace .read(cx) - .dock_at_position(position) - .read(cx) - .is_open(); - if !dock_is_open { - return Vec::new(); + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + { + if let Some(text) = terminal_view + .read(cx) + .terminal() + .read(cx) + .last_content + .selection_text + .clone() + .filter(|text| !text.is_empty()) + { + selections.push(text); + } } - panel.read(cx).terminal_selections(cx) + if let Some(panel) = workspace.read(cx).panel::(cx) { + let position = match TerminalSettings::get_global(cx).dock { + TerminalDockPosition::Left => DockPosition::Left, + TerminalDockPosition::Bottom => DockPosition::Bottom, + TerminalDockPosition::Right => DockPosition::Right, + }; + let dock_is_open = workspace + .read(cx) + .dock_at_position(position) + .read(cx) + .is_open(); + if dock_is_open { + selections.extend(panel.read(cx).terminal_selections(cx)); + } + } + + selections } fn selection_ranges( @@ -2213,17 +2229,8 @@ fn selection_ranges( selections .into_iter() - .map(|s| { - let (start, end) = if s.is_empty() { - let row = multi_buffer::MultiBufferRow(s.start.row); - let line_start = text::Point::new(s.start.row, 0); - let line_end = text::Point::new(s.start.row, snapshot.line_len(row)); - (line_start, line_end) - } else { - (s.start, s.end) - }; - snapshot.anchor_after(start)..snapshot.anchor_before(end) - }) + .filter(|s| !s.is_empty()) + .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end)) .flat_map(|range| { let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?; let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?; From dcef83e413c8aecde6b929a172ff185d04fe2142 Mon Sep 17 00:00:00 2001 From: Tim Vermeulen Date: Wed, 1 Apr 2026 16:29:27 +0200 Subject: [PATCH 21/22] editor: Clear previous select mode when clicking on a sticky header (#52636) Clicking on a sticky header causes `selections.select_ranges([anchor..anchor])` to be called, but this does not clear the editor's `selections.select_mode()`, resulting in possible incorrect selections if this is followed up by a shift-click. This PR fixes that with ```diff - selections.select_ranges([anchor..anchor]); + selections.clear_disjoint(); + selections.set_pending_anchor_range(anchor..anchor, SelectMode::Character); ``` which is essentially what `editor.select(SelectPhase::Begin { ... }, ...)` (i.e. a regular single click in the editor) does as well. Before: https://github.com/user-attachments/assets/bcf2647e-a22a-4866-8975-d29e135df148 After: https://github.com/user-attachments/assets/fb82db51-fef1-4b7c-9954-6e076ae0b176 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed bug that caused clicking on a sticky header to not always properly clear the previous selection. --- crates/editor/src/editor_tests.rs | 85 +++++++++++++++++++++++++++++++ crates/editor/src/element.rs | 8 ++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 65a872e6035565bb01fdd78e00d6cf0f35d35ef8..7e397507eda0d800ee9ed6b204ed95e71d50234b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -32309,6 +32309,91 @@ async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { assert_eq!(selections, vec![empty_range(4, 5)]); } +#[gpui::test] +async fn test_clicking_sticky_header_sets_character_select_mode(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }) + }); + }); + }); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + + let buffer = indoc! {" + fn foo() { + let abc = 123; + } + ˇstruct Bar; + "}; + cx.set_state(&buffer); + + cx.update_editor(|editor, _, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + let text_origin_x = cx.update_editor(|editor, _, _| { + editor + .last_position_map + .as_ref() + .unwrap() + .text_hitbox + .bounds + .origin + .x + }); + + cx.update_editor(|editor, window, cx| { + // Double click on `struct` to select it + editor.begin_selection(DisplayPoint::new(DisplayRow(3), 1), false, 2, window, cx); + editor.end_selection(window, cx); + + // Scroll down one row to make `fn foo() {` a sticky header + editor.scroll(gpui::Point { x: 0., y: 1. }, None, window, cx); + }); + cx.run_until_parked(); + + // Click at the start of the `fn foo() {` sticky header + cx.simulate_click( + gpui::Point { + x: text_origin_x, + y: 0.5 * line_height, + }, + Modifiers::none(), + ); + cx.run_until_parked(); + + // Shift-click at the end of `fn foo() {` to select the whole row + cx.update_editor(|editor, window, cx| { + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + editor.end_selection(window, cx); + }); + cx.run_until_parked(); + + let selections = cx.update_editor(|editor, _, cx| display_ranges(editor, cx)); + assert_eq!( + selections, + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 10)] + ); +} + #[gpui::test] async fn test_next_prev_reference(cx: &mut TestAppContext) { const CYCLE_POSITIONS: &[&'static str] = &[ diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 9ce080c87bf82ec1098e2a4b1db6bc6a65d22828..2cb159546b426b4abae8c201cee5b75aca46f0e4 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -6732,7 +6732,13 @@ impl EditorElement { SelectionEffects::scroll(Autoscroll::top_relative(line_index)), window, cx, - |selections| selections.select_ranges([anchor..anchor]), + |selections| { + selections.clear_disjoint(); + selections.set_pending_anchor_range( + anchor..anchor, + crate::SelectMode::Character, + ); + }, ); cx.stop_propagation(); }); From 4a0c02b69c5ea80318cb94857e8062e1a4e184cb Mon Sep 17 00:00:00 2001 From: Tim Vermeulen Date: Wed, 1 Apr 2026 16:41:50 +0200 Subject: [PATCH 22/22] editor: Prevent blame popover from appearing when cursor is on different pane (#52603) The blame popover shouldn't appear when the cursor hovers over the annotation but on a different pane. Before: https://github.com/user-attachments/assets/dbf6f7b5-e27f-495b-8d6f-fa75a4feee18 After: https://github.com/user-attachments/assets/d5e186df-4ebf-4b4c-bb5f-4d9e7b0f62c7 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed a bug that caused git blame annotations to be hoverable from a different pane. --- crates/editor/src/element.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 2cb159546b426b4abae8c201cee5b75aca46f0e4..2fdb2686ee00ea2fc27881b0c18a54fa85466d9a 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1289,7 +1289,9 @@ impl EditorElement { cx.notify(); } - if let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds { + if text_hovered + && let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds + { let mouse_over_inline_blame = bounds.contains(&event.position); let mouse_over_popover = editor .inline_blame_popover