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 01/25] =?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:
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 02/25] 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 03/25] 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 04/25] 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 05/25] 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 06/25] 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 07/25] 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 08/25] 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 09/25] 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