From b05f40c5546b47bcf9561136dc0fcdcd9968cb63 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 16 Jul 2026 20:23:49 +0300 Subject: [PATCH 01/16] Fix post-taffy-bump layouting issues (#61107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to https://github.com/zed-industries/zed/pull/60721 Before: before image After: after image ### Bug 1 — constraint-blind text layout cache (the collapse) `TextLayout`'s measure closure is stateful (each call overwrites the shared element state used for painting), and its cache check served cached sizes to unconstrained probes regardless of the constraints they were computed under: 1. `uniform_list::measure_item` lays an item out at `(MinContent, MinContent)`; the row's `min_w_0` lets it shrink to ~0, so labels get truncated at a tiny width — and that tiny size lands in the element state. 2. The real layout probes the label's flex base size with `available_space.width = MaxContent` (taffy maps Definite → MaxContent). Truncated labels are `whitespace_nowrap`, so both `wrap_width` and `truncate_width` were `None` → the cache check passed → the poisoned tiny size became the flex basis → labels rendered at ~5 chars. **Fix**: record `truncate_width` in `TextLayoutInner` and never serve a cached layout that was computed *with* truncation to an unconstrained probe; the honest intrinsic size is recomputed instead, and the final `PerformLayout` call re-truncates against the real resolved width. ### Bug 2 — truncation width math disagrees with shaping (the ~2-char loss) After fix 1, rows got their full width, but exact-fit strings like `Cargo.toml` still lost ~2 chars: - Sizing measures via `shape_text` (real shaping with kerning); the element gets exactly `ceil(shaped_width)`. - The truncation decision (`LineWrapper::should_truncate_line_middle` etc.) sums **per-character advances** — no kerning — with zero tolerance, overestimating width for some glyph sequences. Text sitting in a box of exactly its own measured width got truncated, losing ~2 chars to fit the `…`. String-dependent (kerning pairs), hence `Cargo.toml` broke while `Cargo.lock` didn't. - Previously masked because taffy 0.10 usually skipped the final measure invocation, so the truncation path rarely ran with an exact-fit width. **Fix**: before truncating (nowrap case, `wrap_width.is_none()`), shape the untruncated text and skip truncation if the *shaped* width fits — matching CSS `text-overflow`, which only activates on actual overflow. Cheap: the shaping result is a `line_layout_cache` hit from the earlier untruncated sizing probe. ### Bug 3 — `MinContent` used instead of `MaxContent` that caused over truncation of certain labels Release Notes: - N/A --- crates/gpui/src/elements/list.rs | 2 +- crates/gpui/src/elements/text.rs | 26 ++++++++++++++++++++++-- crates/gpui/src/elements/uniform_list.rs | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 62b481bc530..77d33ca2489 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -1038,7 +1038,7 @@ impl StateInner { let mut rendered_focused_item = false; let available_item_space = size( - available_width.map_or(AvailableSpace::MinContent, |width| { + available_width.map_or(AvailableSpace::MaxContent, |width| { AvailableSpace::Definite(width) }), AvailableSpace::MinContent, diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index b64f65a37c5..3470ac94e27 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -618,6 +618,7 @@ struct TextLayoutInner { lines: SmallVec<[WrappedLine; 1]>, line_height: Pixels, wrap_width: Option, + truncate_width: Option, size: Option>, bounds: Option>, } @@ -680,16 +681,20 @@ impl TextLayout { // 2. wrap_width matches (or both are None) // 3. truncate_width is None (if truncate_width is Some, we need to re-layout // because the previous layout may have been computed without truncation) + // 4. the cached layout was not truncated (a truncated layout answers an + // unconstrained probe with the truncated size, which poisons intrinsic + // sizing with whatever width some earlier measure pass happened to use) if let Some(text_layout) = element_state.0.borrow().as_ref() && let Some(size) = text_layout.size && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) && truncate_width.is_none() + && text_layout.truncate_width.is_none() { return size; } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); - let (text, runs) = if truncate_width.is_some() { + let (text, runs) = if let Some(truncate_width) = truncate_width { if let Some(max_lines) = text_style.line_clamp && let Some(wrap_width) = wrap_width { @@ -701,10 +706,25 @@ impl TextLayout { &runs, truncate_from, ) + } else if let Some(unclipped) = window + .text_system() + .shape_text(text.clone(), font_size, &runs, None, None) + .log_err() + && unclipped + .iter() + .all(|line| line.size(line_height).width <= truncate_width) + { + // The truncation decision below sums per-character advances, + // which overestimates the shaped width (no kerning), truncating + // text that fits exactly in its measured width. Skip truncation + // whenever the honestly-shaped text fits; the shaping result + // comes from the line layout cache when the same text was + // already measured untruncated this frame. + (text.clone(), Cow::Borrowed(&*runs)) } else { line_wrapper.truncate_line( text.clone(), - truncate_width.unwrap_or(Pixels::MAX), + truncate_width, &truncation_affix, &runs, truncate_from, @@ -731,6 +751,7 @@ impl TextLayout { len: 0, line_height, wrap_width, + truncate_width, size: Some(Size::default()), bounds: None, }); @@ -749,6 +770,7 @@ impl TextLayout { len, line_height, wrap_width, + truncate_width, size: Some(size), bounds: None, }); diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 0a3314573f0..94ac0021ec8 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -671,7 +671,7 @@ impl UniformList { return Size::default(); }; let available_space = size( - list_width.map_or(AvailableSpace::MinContent, |width| { + list_width.map_or(AvailableSpace::MaxContent, |width| { AvailableSpace::Definite(width) }), AvailableSpace::MinContent, From ae3bbdeb2df000acd8e0c5c75275cbb357e2b223 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Thu, 16 Jul 2026 21:26:49 +0200 Subject: [PATCH 02/16] gpui: Avoid redundant group hover redraws (#61118) Anonymous elements with group hover styles have no persistent hover state. The prepaint mouse handler treated missing state as unhovered and notified the current view for every pointer movement within the group. This made controls such as switches redraw continuously while the pointer moved over them. Let anonymous elements use the existing paint-time hover transition handler. Only elements with persistent hover state now update that state and notify from prepaint. Add regression coverage for entering, moving within, and leaving an anonymous hover group. Signed-off-by: Daan De Meyer Release Notes: - N/A Signed-off-by: Daan De Meyer --- crates/gpui/src/elements/div.rs | 105 +++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 5633371f5b2..c53c76e7871 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2669,8 +2669,8 @@ impl Interactivity { if phase == DispatchPhase::Capture && group_hovered != was_group_hovered { if let Some(hover_state) = &hover_state { hover_state.borrow_mut().group = group_hovered; + cx.notify(current_view); } - cx.notify(current_view); } }); } @@ -4114,9 +4114,108 @@ mod tests { use super::*; use crate::{ AnyWindowHandle, AppContext as _, Context, InputEvent, Keystroke, MouseMoveEvent, - TestAppContext, util::FluentBuilder as _, + TestAppContext, canvas, util::FluentBuilder as _, }; - use std::rc::Weak; + use std::{cell::Cell, rc::Weak}; + + struct GroupHoverTestView { + render_count: Rc>, + anonymous_paint_count: Rc>, + stateful_width: Rc>, + } + + impl Render for GroupHoverTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.render_count.set(self.render_count.get() + 1); + let anonymous_paint_count = self.anonymous_paint_count.clone(); + let stateful_width = self.stateful_width.clone(); + div().size_full().child( + div() + .ml(px(20.)) + .mt(px(20.)) + .size(px(50.)) + .relative() + .group("hover-group") + .child( + div() + .absolute() + .size_full() + .invisible() + .group_hover("hover-group", |style| style.visible()) + .child(canvas( + |_, _, _| {}, + move |_, _, _, _| { + anonymous_paint_count.set(anonymous_paint_count.get() + 1) + }, + )), + ) + .child( + div() + .id("stateful-group-hover-target") + .absolute() + .top_0() + .left_0() + .size(px(10.)) + .group_hover("hover-group", |style| style.size(px(20.))) + .child(canvas( + move |bounds, _, _| stateful_width.set(bounds.size.width), + |_, _, _, _| {}, + )), + ), + ) + } + } + + #[gpui::test] + fn group_hover_styles_update_only_on_transitions(cx: &mut TestAppContext) { + let render_count = Rc::new(Cell::new(0)); + let anonymous_paint_count = Rc::new(Cell::new(0)); + let stateful_width = Rc::new(Cell::new(px(0.))); + let window = cx.add_window({ + let render_count = render_count.clone(); + let anonymous_paint_count = anonymous_paint_count.clone(); + let stateful_width = stateful_width.clone(); + move |_, _| GroupHoverTestView { + render_count, + anonymous_paint_count, + stateful_width, + } + }); + let window = AnyWindowHandle::from(window); + + cx.update_window(window, |_, window, cx| window.draw(cx).clear()) + .unwrap(); + assert_eq!(anonymous_paint_count.get(), 0); + assert_eq!(stateful_width.get(), px(10.)); + + let move_mouse = |cx: &mut TestAppContext, position| { + cx.update_window(window, |_, window, cx| { + window.simulate_mouse_move(position, cx) + }) + .unwrap(); + }; + + let initial_render_count = render_count.get(); + move_mouse(cx, point(px(25.), px(25.))); + assert_eq!(render_count.get(), initial_render_count + 1); + assert_eq!(anonymous_paint_count.get(), 1); + assert_eq!(stateful_width.get(), px(20.)); + + move_mouse(cx, point(px(30.), px(30.))); + assert_eq!(render_count.get(), initial_render_count + 1); + assert_eq!(anonymous_paint_count.get(), 1); + assert_eq!(stateful_width.get(), px(20.)); + + move_mouse(cx, point(px(5.), px(5.))); + assert_eq!(render_count.get(), initial_render_count + 2); + assert_eq!(anonymous_paint_count.get(), 1); + assert_eq!(stateful_width.get(), px(10.)); + + move_mouse(cx, point(px(10.), px(10.))); + assert_eq!(render_count.get(), initial_render_count + 2); + assert_eq!(anonymous_paint_count.get(), 1); + assert_eq!(stateful_width.get(), px(10.)); + } struct TestTooltipView; From f688bee4fe7799f4c8fe1b8d05f32443f935e680 Mon Sep 17 00:00:00 2001 From: Oleksandr Kholiavko <43780952+HalavicH@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:28:22 +0200 Subject: [PATCH 03/16] csv_preview: Add single-line row displaying mode (#61127) ### Changes Adds a checkbox to toggle between multiline cells (current behavior) and single-line rows that clip overflow, so wide CSVs stay compact when full cell content isn't needed at a glance. #### Demo | Before | After | | --- | --- | | No single line mode | image | | image | image | ### Testing - Toggle "Display multiline rows" off: cells clip to one line with ellipsis instead of wrapping - Toggle it back on: cells wrap and grow to show full content - Resize the window / change font size: single-line clip height tracks the actual text line height instead of a stale hardcoded value Release Notes: - N/A --- .gitignore | 2 ++ crates/csv_preview/src/csv_preview.rs | 21 ++++++++----- .../csv_preview/src/renderer/preview_view.rs | 10 +++++- .../csv_preview/src/renderer/render_table.rs | 22 ++++++++++--- .../src/renderer/row_identifiers.rs | 22 +++++-------- crates/csv_preview/src/renderer/settings.rs | 31 ++++++++++++++++--- .../csv_preview/src/renderer/table_header.rs | 1 + crates/csv_preview/src/settings.rs | 9 ++++++ 8 files changed, 85 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index cf75babbcdd..43a48ce78cb 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ crates/docs_preprocessor/actions.json # NixOS integration test state .nixos-test-history + +.local* diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 8f8ba768754..cc86fc63dd7 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -49,6 +49,10 @@ pub struct CsvPreviewView { /// Performance metrics for debugging and monitoring CSV operations. pub(crate) performance_metrics: PerformanceMetrics, pub(crate) list_state: gpui::ListState, + /// Cached row height, refreshed from the actual text line height on every render. + /// Used to size not-yet-rendered rows for the scrollbar without a full `.measure_all()` + /// pass, so it tracks the real row height instead of a hardcoded guess. + pub(crate) row_height: Pixels, /// Time when the last parsing operation ended, used for smart debouncing pub(crate) last_parse_end_time: Option, } @@ -94,7 +98,7 @@ impl CsvPreviewView { .and_then(|item| item.act_as::(cx)) .filter(|editor| Self::is_csv_file(editor, cx)) { - let csv_preview = Self::new(&editor, cx); + let csv_preview = Self::new(&editor, window, cx); workspace.active_pane().update(cx, |pane, cx| { let existing = pane .items_of_type::() @@ -115,7 +119,7 @@ impl CsvPreviewView { .and_then(|item| item.act_as::(cx)) .filter(|editor| Self::is_csv_file(editor, cx)) { - let csv_preview = Self::new(&editor, cx); + let csv_preview = Self::new(&editor, window, cx); let pane = workspace .find_pane_in_direction(SplitDirection::Right, cx) .unwrap_or_else(|| { @@ -152,7 +156,7 @@ impl CsvPreviewView { }); } - fn new(editor: &Entity, cx: &mut Context) -> Entity { + fn new(editor: &Entity, window: &Window, cx: &mut Context) -> Entity { let contents = TableLikeContent::default(); let table_interaction_state = cx.new(|cx| { TableInteractionState::new(cx).with_custom_scrollbar(ui::Scrollbars::for_settings::< @@ -173,6 +177,7 @@ impl CsvPreviewView { }, ); + let row_height = window.pixel_snap(window.line_height()); let mut view = CsvPreviewView { focus_handle: cx.focus_handle(), active_editor_state: EditorState { @@ -186,7 +191,8 @@ impl CsvPreviewView { filter_sort_task: None, performance_metrics: PerformanceMetrics::default(), list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)) - .with_uniform_item_height(px(24.)), + .with_uniform_item_height(row_height), + row_height, settings: CsvPreviewSettings::default(), last_parse_end_time: None, engine: TableDataEngine::default(), @@ -239,11 +245,10 @@ impl CsvPreviewView { this.update(cx, |view, cx| { view.engine.set_d2d_mapping(mapping); let visible_rows = view.engine.d2d_mapping().visible_row_count(); - // Approximation of single csv table row height. Will be re-measured on scrolling. - // This cheap solution allow to render scrollbar with fraction of a cost compared to `.measure_all()` call - let approximate_height = px(24.); + // Uses the row height measured on the last render. Cheaper than a full + // `.measure_all()` pass; exact row heights are re-measured on scrolling. view.list_state - .reset_with_uniform_height(visible_rows, approximate_height); + .reset_with_uniform_height(visible_rows, view.row_height); cx.notify(); }) .ok(); diff --git a/crates/csv_preview/src/renderer/preview_view.rs b/crates/csv_preview/src/renderer/preview_view.rs index 335633656af..f4601429432 100644 --- a/crates/csv_preview/src/renderer/preview_view.rs +++ b/crates/csv_preview/src/renderer/preview_view.rs @@ -7,7 +7,15 @@ use crate::CsvPreviewView; impl Render for CsvPreviewView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); - + let row_height = window.pixel_snap(window.line_height()); + if row_height != self.row_height { + self.row_height = row_height; + // Font size (rem size, buffer font override, ...) changed since the list was last + // measured: existing rows and unmeasured-item height hints are now the wrong size. + // Unlike `reset_with_uniform_height`, this preserves scroll position and keeps each + // item's prior size as a hint rather than dropping straight to a fresh guess. + self.list_state.remeasure(); + } let render_prep_start = Instant::now(); let table_with_settings = v_flex() .size_full() diff --git a/crates/csv_preview/src/renderer/render_table.rs b/crates/csv_preview/src/renderer/render_table.rs index 511e36abd17..9a38a3fedd3 100644 --- a/crates/csv_preview/src/renderer/render_table.rs +++ b/crates/csv_preview/src/renderer/render_table.rs @@ -69,6 +69,7 @@ impl CsvPreviewView { cols, display_row, row_identifier_text_color, + this.row_height, cx, ) .unwrap_or_else(|| panic!("Expected to render a table row")) @@ -83,6 +84,7 @@ impl CsvPreviewView { .rendered_indices .extend(range.clone()); + let row_height = this.row_height; range .filter_map(|display_index| { Self::render_single_table_row( @@ -90,6 +92,7 @@ impl CsvPreviewView { cols, DisplayRow(display_index), row_identifier_text_color, + row_height, cx, ) }) @@ -110,6 +113,7 @@ impl CsvPreviewView { cols: usize, display_row: DisplayRow, row_identifier_text_color: gpui::Hsla, + row_height: Pixels, cx: &Context, ) -> Option> { // Get the actual row index from our sorted indices @@ -128,14 +132,23 @@ impl CsvPreviewView { let display_cell_id = DisplayCellId::new(display_row, col); - let cell = div().size_full().whitespace_nowrap().text_ellipsis().child( - CsvPreviewView::create_selectable_cell( + let cell = div() + .size_full() + .when( + !this.settings.multiline_cells_effectively_enabled(), + |div| { + div.whitespace_nowrap() + .text_ellipsis() + .h(row_height) + .overflow_hidden() + }, + ) + .child(CsvPreviewView::create_selectable_cell( display_cell_id, cell_content, this.settings.vertical_alignment, cx, - ), - ); + )); elements.push( div() @@ -154,7 +167,6 @@ impl CsvPreviewView { }, )) }) - .text_ui(cx) .child(cell) .into_any_element(), ); diff --git a/crates/csv_preview/src/renderer/row_identifiers.rs b/crates/csv_preview/src/renderer/row_identifiers.rs index 770e266186f..b997f645769 100644 --- a/crates/csv_preview/src/renderer/row_identifiers.rs +++ b/crates/csv_preview/src/renderer/row_identifiers.rs @@ -1,12 +1,12 @@ use ui::{ ActiveTheme as _, AnyElement, Button, ButtonCommon as _, ButtonSize, ButtonStyle, - Clickable as _, Context, ElementId, FluentBuilder as _, IntoElement as _, ParentElement as _, - SharedString, Styled as _, StyledTypography as _, Tooltip, div, + Clickable as _, Context, ElementId, IntoElement as _, ParentElement as _, SharedString, + Styled as _, StyledTypography as _, Tooltip, div, }; use crate::{ CsvPreviewView, - settings::{RowIdentifiers, VerticalAlignment}, + settings::RowIdentifiers, types::{DataRow, DisplayRow, LineNumber}, }; @@ -34,7 +34,7 @@ impl LineNumber { if start + 1 == end { format!("{start}\n{end}") } else { - format!("{start}\n...\n{end}") + format!("{start}\n-\n{end}") } } RowIdentDisplayMode::Horizontal => { @@ -78,11 +78,6 @@ impl CsvPreviewView { (max_line_number as f32).log10().floor() as usize + 1 }; - // if !self.settings.multiline_cells_enabled { - // // Uses horizontal line numbers layout like `123-456`. Needs twice the size - // digit_count *= 2; - // } - let char_width_px = 9.0; // TODO: get real width of the characters let base_width = (digit_count as f32) * char_width_px; let padding = 20.0; @@ -157,7 +152,7 @@ impl CsvPreviewView { .contents .line_numbers .get(*data_row)? - .display_string(if self.settings.multiline_cells_enabled { + .display_string(if self.settings.multiline_cells_effectively_enabled() { RowIdentDisplayMode::Vertical } else { RowIdentDisplayMode::Horizontal @@ -172,14 +167,11 @@ impl CsvPreviewView { .border_color(cx.theme().colors().border_variant) .bg(cx.theme().colors().panel_background) .h_full() - .text_ui(cx) .text_color(cx.theme().colors().text_muted) .justify_center() - .map(|div| match self.settings.vertical_alignment { - VerticalAlignment::Top => div.items_start(), - VerticalAlignment::Center => div.items_center(), - }) + .items_center() .font_buffer(cx) + .text_ui(cx) .child(row_identifier) .into_any_element(); Some(value) diff --git a/crates/csv_preview/src/renderer/settings.rs b/crates/csv_preview/src/renderer/settings.rs index 18c185bb0f3..fe8f671f92c 100644 --- a/crates/csv_preview/src/renderer/settings.rs +++ b/crates/csv_preview/src/renderer/settings.rs @@ -1,6 +1,7 @@ use ui::{ - ActiveTheme as _, AnyElement, ButtonSize, Context, ContextMenu, DropdownMenu, ElementId, - IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex, + ActiveTheme as _, AnyElement, ButtonSize, Checkbox, Context, ContextMenu, DropdownMenu, + ElementId, IntoElement as _, ParentElement as _, Styled as _, ToggleState, Tooltip, Window, + div, h_flex, }; use crate::{ @@ -121,6 +122,30 @@ impl CsvPreviewView { ), ); + let multiline_enabled = self.settings.multiline_cells_enabled; + let panel = panel.child({ + let view = view.clone(); + Checkbox::new( + ElementId::Name("multiline-rows-checkbox".into()), + if multiline_enabled { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Display multiline rows") + .tooltip(Tooltip::text( + "When enabled, row height grows to show all content. \ + When disabled, only the first line is visible — hover a cell to see the rest.", + )) + .on_click(move |_state, _window, cx| { + view.update(cx, |this, cx| { + this.settings.multiline_cells_enabled = !this.settings.multiline_cells_enabled; + cx.notify(); + }); + }) + }); + #[cfg(feature = "dev-tools")] let panel = panel.child( h_flex() @@ -171,7 +196,6 @@ fn create_dev_only_popover_menu( view_entity.update(cx, |view, cx| { view.settings.rendering_with = RowRenderMechanism::VariableList; - view.settings.multiline_cells_enabled = true; cx.notify(); }) } @@ -188,7 +212,6 @@ fn create_dev_only_popover_menu( view_entity.update(cx, |view, cx| { view.settings.rendering_with = RowRenderMechanism::UniformList; - view.settings.multiline_cells_enabled = false; cx.notify(); }) } diff --git a/crates/csv_preview/src/renderer/table_header.rs b/crates/csv_preview/src/renderer/table_header.rs index 6549cb51520..444ee090993 100644 --- a/crates/csv_preview/src/renderer/table_header.rs +++ b/crates/csv_preview/src/renderer/table_header.rs @@ -44,6 +44,7 @@ impl CsvPreviewView { .w_full() .items_center() .font_buffer(cx) + .text_buffer(cx) .child( div() .flex_1() diff --git a/crates/csv_preview/src/settings.rs b/crates/csv_preview/src/settings.rs index 2d5cc78c5f5..dc871fa4b38 100644 --- a/crates/csv_preview/src/settings.rs +++ b/crates/csv_preview/src/settings.rs @@ -46,3 +46,12 @@ pub(crate) struct CsvPreviewSettings { pub(crate) show_perf_metrics_overlay: bool, pub(crate) multiline_cells_enabled: bool, } + +impl CsvPreviewSettings { + /// `multiline_cells_enabled` only makes sense with `VariableList`, which + /// supports per-row heights; `UniformList` requires every row to share one + /// height, so multiline is never honored there regardless of the setting. + pub(crate) fn multiline_cells_effectively_enabled(&self) -> bool { + self.multiline_cells_enabled && self.rendering_with == RowRenderMechanism::VariableList + } +} From 058f01fa93503491a735bfded53e77bfaa276148 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 17 Jul 2026 01:15:21 +0530 Subject: [PATCH 04/16] gpui: Fix clicks on window prompts dismissing popovers behind them (#61136) I noticed that when Zed renders prompts in-window (Linux, or `use_system_prompts: false`), clicking a prompt button also dismisses the popover behind it. Native macOS dialogs don't have this problem since the click goes to the OS dialog and the window never sees it. For example, in the worktree picker: 1. Delete a dirty worktree. 2. Confirm "Force Delete". 3. Mouse down dismissed the picker, which aborted the confirmed deletion. This PR makes GPUI-rendered prompts behave the same way as native. If someone actually wants to observe mouse downs during a prompt, the raw `window.on_mouse_event` API still sees every event. macOS: https://github.com/user-attachments/assets/9f85622f-c0d1-44f2-83f9-8378d9d7d13b Before GPUI prompt: https://github.com/user-attachments/assets/d1845ba1-9886-4733-8cf6-ab32424416e1 After GPUI prompt: https://github.com/user-attachments/assets/27936aca-8de5-4da8-88be-d88dbc1259d8 Release Notes: - Fixed confirmation dialog buttons dismissing the popover underneath them on click when using Zed-rendered prompts. --- crates/gpui/src/elements/div.rs | 86 ++++++++++++++++++++++++++++++++- crates/gpui/src/window.rs | 8 +++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index c53c76e7871..c02bd16b77e 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -262,7 +262,10 @@ impl Interactivity { ) { self.mouse_down_listeners .push(Box::new(move |event, phase, hitbox, window, cx| { - if phase == DispatchPhase::Capture && !hitbox.contains(&window.mouse_position()) { + if phase == DispatchPhase::Capture + && !window.has_active_prompt() + && !hitbox.contains(&window.mouse_position()) + { (listener)(event, window, cx) } })); @@ -4523,6 +4526,87 @@ mod tests { assert!(active_tooltip.borrow().is_none()); } + struct MouseDownOutOwner { + mouse_down_out_count: Rc>, + } + + impl Render for MouseDownOutOwner { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let mouse_down_out_count = self.mouse_down_out_count.clone(); + div() + .size_full() + .child(div().id("target").w(px(50.)).h(px(50.)).on_mouse_down_out( + move |_, _, _| { + *mouse_down_out_count.borrow_mut() += 1; + }, + )) + } + } + + #[test] + fn mouse_down_out_is_suppressed_while_window_prompt_is_active() { + let mut test_app = TestAppContext::single(); + let mouse_down_out_count = Rc::new(RefCell::new(0)); + let window = test_app.add_window({ + let mouse_down_out_count = mouse_down_out_count.clone(); + move |_, _| MouseDownOutOwner { + mouse_down_out_count, + } + }); + let any_window: AnyWindowHandle = window.into(); + + fn dispatch_mouse_down_outside_target( + test_app: &mut TestAppContext, + any_window: AnyWindowHandle, + ) { + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + MouseDownEvent { + position: point(px(75.), px(75.)), + button: MouseButton::Left, + modifiers: Default::default(), + click_count: 1, + first_mouse: false, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + } + + test_app + .update_window(any_window, |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + + dispatch_mouse_down_outside_target(&mut test_app, any_window); + assert_eq!( + *mouse_down_out_count.borrow(), + 1, + "mouse down outside the element should fire mouse-down-out listeners" + ); + + test_app + .update_window(any_window, |_, window, cx| { + cx.set_prompt_builder(crate::fallback_prompt_renderer); + let _receiver = + window.prompt(crate::PromptLevel::Warning, "message", None, &["Ok"], cx); + assert!(window.has_active_prompt()); + window.draw(cx).clear(); + }) + .unwrap(); + + dispatch_mouse_down_outside_target(&mut test_app, any_window); + assert_eq!( + *mouse_down_out_count.borrow(), + 1, + "mouse down over an active prompt should not fire mouse-down-out listeners" + ); + } + #[test] fn test_write_a11y_info_string_and_numeric_properties() { let mut interactivity = Interactivity::default(); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 4c8b4651c8c..6a2bbf7874b 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5343,6 +5343,14 @@ impl Window { receiver } + /// Returns whether a prompt rendered by GPUI is currently active in this window. + /// + /// This is only true for prompts rendered in the window (see + /// [`App::set_prompt_builder`]), not for platform-native prompt dialogs. + pub fn has_active_prompt(&self) -> bool { + self.prompt.is_some() + } + /// Returns the current context stack. pub fn context_stack(&self) -> Vec { let node_id = self.focus_node_id_in_rendered_frame(self.focus); From c7ee116ead3476eaf6f34a8bbb833f628d300959 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 17 Jul 2026 11:29:19 +0530 Subject: [PATCH 05/16] vtsls: Fix invalid tsserver install error in TypeScript 7 projects (#61126) Closes #60761 Follow-up to #60970, which fixed this for `typescript-language-server`. `vtsls` had the same problem, we only checked that the workspace tsdk directory exists, but TypeScript 7 ships it without `tsserver.js`, causing vtsls to error on startup. Now we do the same `tsserver.js` check, so vtsls silently falls back to its bundled TypeScript (see #60951). Release Notes: - Fixed vtsls showing a "doesn't point to a valid tsserver install" error on startup in projects using TypeScript 7. For full TypeScript 7 support, install the `tsgo` extension to use TypeScript's native language server. --- crates/languages/src/typescript.rs | 2 ++ crates/languages/src/vtsls.rs | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index a23b66021c1..0da1b63590e 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -637,6 +637,8 @@ impl TypeScriptLspAdapter { "node_modules/typescript/lib" }; + // typescript-language-server doesn't support TypeScript 7+, which no longer + // ships `tsserver.js`. if self .fs .is_file( diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 426f09d6c94..5be98804d69 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -57,9 +57,15 @@ impl VtslsLspAdapter { Self::TYPESCRIPT_TSDK_PATH }; + // vtsls doesn't support TypeScript 7+, which no longer ships `tsserver.js`. if self .fs - .is_dir(&adapter.worktree_root_path().join(tsdk_path)) + .is_file( + &adapter + .worktree_root_path() + .join(tsdk_path) + .join("tsserver.js"), + ) .await { Some(tsdk_path) From a3f6ef252b6de19d22a1223952fc335253163642 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 17 Jul 2026 10:41:28 +0300 Subject: [PATCH 06/16] Add more parameter colors to the theme (#61134) Follow-up to https://github.com/zed-industries/zed/pull/60949 based on https://github.com/zed-industries/zed/pull/60949#issuecomment-4994719726 `basedpyright` sends the token we lack the theme element for before supports that in theme definitions: after --- Release Notes: - Added `variable.parameter` theme color --- assets/themes/ayu/ayu.json | 15 +++++++++++++++ assets/themes/gruvbox/gruvbox.json | 30 ++++++++++++++++++++++++++++++ assets/themes/one/one.json | 10 ++++++++++ 3 files changed, 55 insertions(+) diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index f27566c4f72..ee5073e7a44 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -387,6 +387,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#d2a6ffff", + "font_style": null, + "font_weight": null + }, "variant": { "color": "#5ac1feff", "font_style": null, @@ -789,6 +794,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#a37accff", + "font_style": null, + "font_weight": null + }, "variant": { "color": "#3b9ee5ff", "font_style": null, @@ -1191,6 +1201,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#dfbfffff", + "font_style": null, + "font_weight": null + }, "variant": { "color": "#72cffeff", "font_style": null, diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 4330df54fcc..75252f781da 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -397,6 +397,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#83a598ff", "font_style": null, @@ -814,6 +819,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#83a598ff", "font_style": null, @@ -1231,6 +1241,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#83a598ff", "font_style": null, @@ -1648,6 +1663,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#076678ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#066578ff", "font_style": null, @@ -2065,6 +2085,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#076678ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#066578ff", "font_style": null, @@ -2482,6 +2507,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#076678ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#066578ff", "font_style": null, diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index b6b4dfd0c63..698b8febcea 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -394,6 +394,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#d07277ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#bf956aff", "font_style": null, @@ -806,6 +811,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#d3604fff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#ad6e25ff", "font_style": null, From aa42d3adce0f85abec817d99124b4cedb6d5f359 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 17 Jul 2026 10:42:28 +0300 Subject: [PATCH 07/16] Render nested tabstops correctly (#61130) Closes https://github.com/zed-industries/zed/issues/61112 Now behaves similar to VSCode: 1 2 Release Notes: - Fixed rust-analyzer's macro with nested tabstops causing panics when completed --- crates/languages/src/rust.rs | 68 +++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 4091be97ed3..16e0ff01fd5 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -489,27 +489,56 @@ impl LspAdapter for RustLspAdapter { .collect::>(); all_stop_ranges.sort_unstable_by_key(|a| (a.start, Reverse(a.end))); + // Placeholders may nest, e.g. `$2` inside `${1:"$2"}` + struct OpenPlaceholder { + snippet_text_end: usize, + label_run_start: usize, + } + let mut open_placeholders = SmallVec::<[OpenPlaceholder; 4]>::new(); + for range in &all_stop_ranges { let start_pos = range.start as usize; let end_pos = range.end as usize; + while let Some(placeholder) = open_placeholders.last() { + if placeholder.snippet_text_end > start_pos { + break; + } + label.push_str(&snippet.text[text_pos..placeholder.snippet_text_end]); + text_pos = placeholder.snippet_text_end; + runs.push(( + placeholder.label_run_start..label.len(), + HighlightId::TABSTOP_REPLACE_ID, + )); + open_placeholders.pop(); + } + label.push_str(&snippet.text[text_pos..start_pos]); + text_pos = start_pos; if start_pos == end_pos { let caret_start = label.len(); label.push('…'); runs.push((caret_start..label.len(), HighlightId::TABSTOP_INSERT_ID)); } else { - let label_start = label.len(); - label.push_str(&snippet.text[start_pos..end_pos]); - let label_end = label.len(); - runs.push((label_start..label_end, HighlightId::TABSTOP_REPLACE_ID)); + open_placeholders.push(OpenPlaceholder { + snippet_text_end: end_pos, + label_run_start: label.len(), + }); } + } - text_pos = end_pos; + while let Some(placeholder) = open_placeholders.pop() { + label.push_str(&snippet.text[text_pos..placeholder.snippet_text_end]); + text_pos = placeholder.snippet_text_end; + runs.push(( + placeholder.label_run_start..label.len(), + HighlightId::TABSTOP_REPLACE_ID, + )); } label.push_str(&snippet.text[text_pos..]); + runs.sort_unstable_by_key(|(range, _)| (range.start, Reverse(range.end))); if detail_left.is_some_and(|detail_left| detail_left == new_text) { // We only include the left detail if it isn't the snippet again @@ -1453,6 +1482,7 @@ mod tests { use crate::language; use gpui::{BorrowAppContext, Hsla, TestAppContext}; use lsp::CompletionItemLabelDetails; + use pretty_assertions::assert_eq; use settings::SettingsStore; use theme::SyntaxTheme; use util::path; @@ -1876,6 +1906,34 @@ mod tests { )) ); + assert_eq!( + adapter + .label_for_completion( + &lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::SNIPPET), + label: "unimplemented".to_string(), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::default(), + new_text: "unimplemented!(${1:\"$2\"})".to_string(), + })), + ..lsp::CompletionItem::default() + }, + &language, + ) + .await, + Some(CodeLabel::new( + "unimplemented!(\"…\")".to_string(), + 0..13, + vec![ + (15..20, HighlightId::TABSTOP_REPLACE_ID), + (16..19, HighlightId::TABSTOP_INSERT_ID), + (0..13, HighlightId::new(2)), + (13..14, HighlightId::new(2)), + ], + )) + ); + // Postfix completion without actual tabstops (only implicit final $0) // The label should use completion.label so it can be filtered by "ref" let ref_completion = adapter From 9552acc2bc242d45342fa9b5a987d43868aee1ec Mon Sep 17 00:00:00 2001 From: saberoueslati Date: Fri, 17 Jul 2026 09:29:17 +0100 Subject: [PATCH 08/16] gpui: Apply EXIF orientation when decoding static images (#61017) ## Context Static images decoded by GPUI ignored their EXIF `Orientation` tag, so JPEGs that rely on it (photos from phones and cameras) rendered rotated or flipped in image preview, Markdown preview, and other `ImageSource` consumers. The `image` crate only exposes orientation through `ImageDecoder::orientation()`, which must be read before the decoder is consumed; `DynamicImage::from_decoder` does not apply it. The old path went straight through `image::load_from_memory_with_format`, so the metadata was never read. Closes #60844. Video of manual test below : [Screencast from 2026-07-15 02-14-41.webm](https://github.com/user-attachments/assets/002b0324-44f8-4ddb-acbd-8c3e070d5248) ## How to Review **Cargo.toml** Raises the workspace `image` dependency minimum from `0.25.1` to `0.25.4`, the first release exposing `orientation()` and `apply_orientation()`. The lockfile already resolves to `0.25.10`, so no lock change is required. **crates/gpui/src/platform.rs** Adds two crate-private helpers. `decode_static_image_from_decoder` reads `orientation()`, decodes to a `DynamicImage`, applies the orientation, then converts RGBA to GPUI's BGRA. `decode_static_image` builds a format decoder from bytes and delegates to it. `Image::to_image_data` now routes its static formats through these helpers instead of `image::load_from_memory_with_format`. Adds `test_image_to_image_data_applies_exif_orientation`, which decodes a 16x32 fixture stored upside down with EXIF Orientation 3 and asserts the corner pixels were flipped upright; the test fails if orientation is dropped. **crates/gpui/src/elements/img.rs** Routes `ImageAssetLoader` (Markdown paths, URLs, embedded resources) through the same helpers, including the static WebP branch. GIF and animated-WebP frame handling are left unchanged, since animated orientation is out of scope. ## 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 - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed static images ignoring their EXIF orientation, which caused some JPEGs to render rotated or flipped in image and Markdown preview. --- .../image/exif-orientation-rotate-180.jpg | Bin 0 -> 378 bytes crates/gpui/examples/image/image.rs | 6 +- crates/gpui/src/elements/img.rs | 26 ++---- crates/gpui/src/platform.rs | 75 ++++++++++++------ 4 files changed, 61 insertions(+), 46 deletions(-) create mode 100644 crates/gpui/examples/image/exif-orientation-rotate-180.jpg diff --git a/crates/gpui/examples/image/exif-orientation-rotate-180.jpg b/crates/gpui/examples/image/exif-orientation-rotate-180.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9c095652a1697a63ce5b6eff0526baa4b51bafaf GIT binary patch literal 378 zcmex=M1EUawo9GqO-Km}U`7?>ED znVDFaSy@?FfU0YO@(e73tU`*0j%>n#iR?;+B1Vl97jh^&Z9FI%bn%0VaZ*teCzqJG zgrtbvx}>nyN9P&a7buactm7Wa!P7idPZheaY<=ec|~Pab4zPm zdq-#2q{&mJPMbbs=B!1Fmn>bje8tLDn>KIRx^4T8ox2VlK63Qf@e?OcUAlbb>b2`P zZr*zM=<$=M&z`?{`Re1R&tJZN`~KtSum86gI6y&b&+wnY{+Gt0{r4YrX{`T$698_d BZD;@h literal 0 HcmV?d00001 diff --git a/crates/gpui/examples/image/image.rs b/crates/gpui/examples/image/image.rs index 45fce26d046..688f881bc6d 100644 --- a/crates/gpui/examples/image/image.rs +++ b/crates/gpui/examples/image/image.rs @@ -100,7 +100,7 @@ impl Render for ImageShowcase { .items_center() .gap_8() .child(ImageContainer::new( - "Image loaded from a local file", + "Image loaded from a local file with EXIF orientation", self.local_resource.clone(), )) .child(ImageContainer::new( @@ -202,7 +202,9 @@ fn run_example() { cx.open_window(window_options, |_, cx| { cx.new(|_| ImageShowcase { // Relative path to your root project path - local_resource: manifest_dir.join("examples/image/app-icon.png").into(), + local_resource: manifest_dir + .join("examples/image/exif-orientation-rotate-180.jpg") + .into(), remote_resource: "https://picsum.photos/800/400".into(), asset_resource: "image/color.svg".into(), }) diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 4850d895b63..7e92ecba3d0 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -2,14 +2,15 @@ use crate::{ AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId, Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource, - SharedString, SharedUri, StyleRefinement, Styled, Task, Window, px, + SharedString, SharedUri, StyleRefinement, Styled, Task, Window, decode_static_image, + decode_static_image_from_decoder, px, }; use anyhow::Result; use futures::Future; use gpui_util::ResultExt; use image::{ - AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba, + AnimationDecoder, ImageError, ImageFormat, Rgba, codecs::{gif::GifDecoder, webp::WebPDecoder}, }; use scheduler::Instant; @@ -716,27 +717,10 @@ impl Asset for ImageAssetLoader { frames } else { - let mut data = DynamicImage::from_decoder(decoder)?.into_rgba8(); - - // Convert from RGBA to BGRA. - for pixel in data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - - SmallVec::from_elem(Frame::new(data), 1) + decode_static_image_from_decoder(decoder)? } } - _ => { - let mut data = - image::load_from_memory_with_format(&bytes, format)?.into_rgba8(); - - // Convert from RGBA to BGRA. - for pixel in data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - - SmallVec::from_elem(Frame::new(data), 1) - } + _ => decode_static_image(&bytes, format)?, }; Ok(Arc::new(RenderImage::new(data))) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index df7973909c5..3caf351fc86 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -42,15 +42,15 @@ use crate::{ RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, }; -use anyhow::Result; #[cfg(any(target_os = "linux", target_os = "freebsd"))] use anyhow::bail; +use anyhow::{Context as _, Result}; use async_task::Runnable; use futures::channel::oneshot; #[cfg(any(test, feature = "test-support"))] use image::RgbaImage; use image::codecs::gif::GifDecoder; -use image::{AnimationDecoder as _, Frame}; +use image::{AnimationDecoder as _, DynamicImage, Frame}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use scheduler::Instant; pub use scheduler::RunnableMeta; @@ -2368,6 +2368,33 @@ pub struct Image { pub id: u64, } +pub(crate) fn decode_static_image( + bytes: &[u8], + format: image::ImageFormat, +) -> Result> { + let decoder = image::ImageReader::with_format(Cursor::new(bytes), format) + .into_decoder() + .context("creating image decoder")?; + decode_static_image_from_decoder(decoder) +} + +pub(crate) fn decode_static_image_from_decoder( + mut decoder: impl image::ImageDecoder, +) -> Result> { + let orientation = decoder + .orientation() + .context("reading decoder's orientation")?; + let mut image = DynamicImage::from_decoder(decoder).context("decoding image")?; + image.apply_orientation(orientation); + + let mut data = image.into_rgba8(); + for pixel in data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + + Ok(SmallVec::from_elem(Frame::new(data), 1)) +} + impl Hash for Image { fn hash(&self, state: &mut H) { state.write_u64(self.id); @@ -2423,20 +2450,6 @@ impl Image { /// Convert the clipboard image to an `ImageData` object. pub fn to_image_data(&self, svg_renderer: SvgRenderer) -> Result> { - fn frames_for_image( - bytes: &[u8], - format: image::ImageFormat, - ) -> Result> { - let mut data = image::load_from_memory_with_format(bytes, format)?.into_rgba8(); - - // Convert from RGBA to BGRA. - for pixel in data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - - Ok(SmallVec::from_elem(Frame::new(data), 1)) - } - let frames = match self.format { ImageFormat::Gif => { let decoder = GifDecoder::new(Cursor::new(&self.bytes))?; @@ -2463,18 +2476,18 @@ impl Image { frames } - ImageFormat::Png => frames_for_image(&self.bytes, image::ImageFormat::Png)?, - ImageFormat::Jpeg => frames_for_image(&self.bytes, image::ImageFormat::Jpeg)?, - ImageFormat::Webp => frames_for_image(&self.bytes, image::ImageFormat::WebP)?, - ImageFormat::Bmp => frames_for_image(&self.bytes, image::ImageFormat::Bmp)?, - ImageFormat::Tiff => frames_for_image(&self.bytes, image::ImageFormat::Tiff)?, - ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?, + ImageFormat::Png => decode_static_image(&self.bytes, image::ImageFormat::Png)?, + ImageFormat::Jpeg => decode_static_image(&self.bytes, image::ImageFormat::Jpeg)?, + ImageFormat::Webp => decode_static_image(&self.bytes, image::ImageFormat::WebP)?, + ImageFormat::Bmp => decode_static_image(&self.bytes, image::ImageFormat::Bmp)?, + ImageFormat::Tiff => decode_static_image(&self.bytes, image::ImageFormat::Tiff)?, + ImageFormat::Ico => decode_static_image(&self.bytes, image::ImageFormat::Ico)?, ImageFormat::Svg => { return svg_renderer .render_single_frame(&self.bytes, 1.0) .map_err(Into::into); } - ImageFormat::Pnm => frames_for_image(&self.bytes, image::ImageFormat::Pnm)?, + ImageFormat::Pnm => decode_static_image(&self.bytes, image::ImageFormat::Pnm)?, }; Ok(Arc::new(RenderImage::new(frames))) @@ -2559,6 +2572,22 @@ mod image_tests { use super::*; use std::sync::Arc; + #[test] + fn test_image_to_image_data_applies_exif_orientation() { + let image = Image::from_bytes( + ImageFormat::Jpeg, + include_bytes!("../examples/image/exif-orientation-rotate-180.jpg").to_vec(), + ); + + let render_image = image.to_image_data(SvgRenderer::new(Arc::new(()))).unwrap(); + + assert_eq!(render_image.size(0), size(16.into(), 32.into())); + + let bytes = render_image.as_bytes(0).unwrap(); + assert_eq!(&bytes[..4], &[255, 255, 255, 255]); + assert_eq!(&bytes[(16 * 32 - 1) * 4..], &[0, 0, 0, 255]); + } + #[test] fn test_svg_image_to_image_data_converts_to_bgra() { let image = Image::from_bytes( From 94e59ce2f83a3aaaf80efa2fd2c6a033994b0b34 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:43:11 +0900 Subject: [PATCH 09/16] git_graph: Fix long commit message overflow in detail panel (#61163) Follow-up to zed-industries/zed#60721. The Taffy 0.12 upgrade exposed that the message height limit was applied to its grid parent while percentage-sized children retained the Markdown content's intrinsic height. Long messages therefore painted over the changed-files summary instead of scrolling. Move the height limit to the scroll container itself, matching the commit view. Add a GPUI regression test that verifies the viewport stays bounded, remains scrollable, and does not overlap the changed-files list. > On `main`: Screenshot 2026-07-17 at 14 59 21 --- Release Notes: - N/A --- crates/git_ui/src/git_graph.rs | 132 ++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 43995457906..f0e4151d8e0 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -3725,7 +3725,6 @@ impl GitGraph { // grid, on the other hand, doesn't appear to give taffy the same // problems. .w_full() - .max_h(line_height * 12.) .py_2() .pl_2() .grid() @@ -3740,6 +3739,7 @@ impl GitGraph { .id("commit-message") .text_sm() .size_full() + .max_h(line_height * 12.) .overflow_y_scroll() .track_scroll(scroll_handle) .child(MarkdownElement::new(message.clone(), message_style)), @@ -7381,6 +7381,136 @@ mod tests { }); } + #[gpui::test] + async fn test_long_commit_message_is_constrained_to_scroll_viewport(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let commit_sha = Oid::from_bytes(&[1; 20]).expect("commit SHA should be valid"); + let commits = vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: smallvec![], + ref_names: vec!["HEAD -> main".into()], + })]; + fs.set_graph_commits(Path::new("/project/.git"), commits); + + let message = (0..40) + .map(|line_number| { + format!( + "Line {line_number}: This commit message is long enough to require scrolling." + ) + }) + .collect::>() + .join("\n\n"); + fs.set_commit_data( + Path::new("/project/.git"), + [( + CommitData { + sha: commit_sha, + parents: smallvec![], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 1_700_000_000, + subject: "Long commit message".into(), + message: message.into(), + }, + false, + )], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + cx.run_until_parked(); + + git_graph.update_in(cx, |graph, window, cx| { + graph.select_first(&menu::SelectFirst, window, cx); + }); + cx.run_until_parked(); + + git_graph.update(cx, |graph, cx| { + graph.selected_commit_diff = Some(CommitDiff { + files: vec![CommitFile { + path: RepoPath::new("file.txt").expect("repository path should be valid"), + old_text: Some("content".into()), + new_text: Some("updated content".into()), + is_binary: false, + }], + }); + graph.selected_commit_diff_stats = Some((1, 1)); + cx.notify(); + }); + + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| git_graph.clone().into_any_element(), + ); + cx.run_until_parked(); + + let (message_scroll_handle, changed_files_scroll_handle) = + git_graph.read_with(&*cx, |graph, _| { + ( + graph + .selected_commit_message + .as_ref() + .expect("selected commit message should be loaded") + .scroll_handle + .clone(), + graph.changed_files_scroll_handle.clone(), + ) + }); + let maximum_message_height = git_graph.update_in(cx, |_, window, cx| { + editor::hover_markdown_style(window, cx) + .base_text_style + .line_height_in_pixels(window.rem_size()) + * 12. + }); + let message_bounds = message_scroll_handle.bounds(); + let changed_files_bounds = changed_files_scroll_handle.0.borrow().base_handle.bounds(); + + assert!( + message_bounds.size.height <= maximum_message_height, + "commit message viewport height ({}) should not exceed its maximum ({})", + message_bounds.size.height, + maximum_message_height, + ); + assert!( + message_scroll_handle.max_offset().y > px(0.), + "long commit message should be scrollable" + ); + assert!( + message_bounds.bottom() <= changed_files_bounds.top(), + "commit message viewport {message_bounds:?} should not overlap changed files {changed_files_bounds:?}" + ); + } + #[gpui::test] async fn test_commit_message_not_reloaded_for_same_sha(cx: &mut TestAppContext) { init_test(cx); From c7148c8190d7740d51f6e380f993ba589aaf0751 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 17 Jul 2026 10:45:02 +0200 Subject: [PATCH 10/16] project: Fix agent hanging indefinitely when pulling workspace diagnostics (#61176) The agent's diagnostics tool waits on pull_workspace_diagnostics_once, which enqueues a completion signal into the background workspace diagnostics refresh loop. When a language server failed to answer workspace/diagnostic requests, that signal could be stranded, leaving the agent stuck on 'Check project diagnostics' (reported with logs full of 'Timeout during workspace diagnostics pull' and requests cancelled after 120s). Three hang paths in lsp_workspace_diagnostics_refresh: 1. Timeouts never resolved the completion signal. On ConnectionResult::Timeout the loop just retried, holding the waiter through up to 50 attempts x 120s each (~100 minutes) before the sender was finally dropped. 2. Waiters could queue behind an already-hanging request. The refresh channel has capacity 1; if the loop was mid-retry (e.g. the initial pull at server startup was timing out), a newly enqueued waiter sat unreceived until the entire retry cycle ended. 3. The request timeout was permanently disabled after the first partial result. The timer raced progress_rx.recv() once and became pending() forever if progress won, so a server that streamed one chunk and then stalled hung the request indefinitely. Fixes: - On timeout, release all pending waiters (including ones that queued mid-flight) with refreshed = false, so callers fall back to cached diagnostics after at most one timeout period while the background loop keeps retrying. - Carry waiters as a Vec and absorb refresh requests queued during backoff/in-flight requests into the current attempt instead of serializing them behind it. - Restart the timeout whenever a partial result arrives, turning it into an inactivity timeout: streaming servers get unlimited total time while they keep making progress, but a stalled stream now times out instead of hanging forever. Adds a regression test with a fake server that never answers workspace diagnostic pulls, verifying the waiter resolves after the timeout; it fails with 'Parking forbidden' (infinite hang) without the fix. Closes FR-124 Release Notes: - Fixed agents hanging indefinitely if an LSP server never responds to diagnostics requests --- crates/project/src/lsp_store.rs | 48 ++++++++++---- .../tests/integration/project_tests.rs | 65 +++++++++++++++++++ 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 38117a9731c..c1e5ca31f53 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -61,7 +61,7 @@ use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; use futures::{ AsyncWriteExt, Future, FutureExt, StreamExt, channel::oneshot, - future::{Either, Shared, join_all, pending, select}, + future::{Either, Shared, join_all, select}, select, select_biased, stream::FuturesUnordered, }; @@ -123,7 +123,6 @@ use std::{ collections::{VecDeque, hash_map}, convert::TryInto, ffi::OsStr, - future::ready, iter, mem, ops::{ControlFlow, Range}, path::{self, Path, PathBuf}, @@ -14152,9 +14151,10 @@ fn lsp_workspace_diagnostics_refresh( let mut requests = 0; loop { - let Some(mut completion_tx) = refresh_rx.recv().await else { + let Some(completion_tx) = refresh_rx.recv().await else { return; }; + let mut completion_txs = completion_tx.into_iter().collect::>(); 'request: loop { requests += 1; @@ -14170,6 +14170,12 @@ fn lsp_workspace_diagnostics_refresh( .await; attempts += 1; + // Absorb refresh requests that queued up in the meantime, so their + // waiters are resolved by this attempt instead of waiting behind it. + while let Ok(queued_refresh) = refresh_rx.try_recv() { + completion_txs.extend(queued_refresh); + } + let Ok(previous_result_ids) = lsp_store.update(cx, |lsp_store, _| { lsp_store .result_ids_for_workspace_refresh(server.server_id(), ®istration_id_shared) @@ -14196,8 +14202,21 @@ fn lsp_workspace_diagnostics_refresh( }; progress_rx.try_recv().ok(); - let timer = server.request_timer(timeout).fuse(); - let progress = pin!(progress_rx.recv().fuse()); + // Restart the timeout whenever a partial result arrives: streaming + // servers may legitimately take longer than a single timeout period, + // but a server that stops streaming without completing the request + // should still time out instead of hanging forever. + let timer = async { + loop { + let timer = pin!(server.request_timer(timeout).fuse()); + let progress = pin!(progress_rx.recv().fuse()); + match select(timer, progress).await { + Either::Left((message, ..)) => break message, + Either::Right((Some(()), ..)) => {} + Either::Right((None, timer)) => break timer.await, + } + } + }; let response_result = server .request_with_timer::( lsp::WorkspaceDiagnosticParams { @@ -14208,10 +14227,7 @@ fn lsp_workspace_diagnostics_refresh( partial_result_token: Some(lsp::ProgressToken::String(token)), }, }, - select(timer, progress).then(|either| match either { - Either::Left((message, ..)) => ready(message).left_future(), - Either::Right(..) => pending::().right_future(), - }), + timer, ) .await; @@ -14220,6 +14236,16 @@ fn lsp_workspace_diagnostics_refresh( match response_result { ConnectionResult::Timeout => { log::error!("Timeout during workspace diagnostics pull"); + // Release everyone waiting on this refresh (e.g. the agent's + // diagnostics tool), including waiters that queued up while the + // request was in flight, so they can fall back to cached + // diagnostics instead of waiting through every retry. + while let Ok(queued_refresh) = refresh_rx.try_recv() { + completion_txs.extend(queued_refresh); + } + for tx in completion_txs.drain(..) { + tx.send(false).ok(); + } continue 'request; } ConnectionResult::ConnectionReset => { @@ -14228,7 +14254,7 @@ fn lsp_workspace_diagnostics_refresh( } ConnectionResult::Result(Err(e)) => { log::error!("Error during workspace diagnostics pull: {e:#}"); - if let Some(tx) = completion_tx.take() { + for tx in completion_txs.drain(..) { tx.send(false).ok(); } break 'request; @@ -14248,7 +14274,7 @@ fn lsp_workspace_diagnostics_refresh( { return; } - if let Some(tx) = completion_tx.take() { + for tx in completion_txs.drain(..) { tx.send(true).ok(); } break 'request; diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 6ec9f345021..bf90db372ea 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -4644,6 +4644,71 @@ async fn test_diagnostic_summaries_retained_on_buffer_close_with_workspace_diagn }); } +#[gpui::test] +async fn test_workspace_diagnostics_pull_timeout_releases_waiters(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( + lsp::DiagnosticOptions { + identifier: Some("test-ws-timeout".to_string()), + inter_file_dependencies: true, + workspace_diagnostics: true, + work_done_progress_options: Default::default(), + }, + )), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + // Simulate a server that holds workspace diagnostic pull requests + // open forever without responding or streaming partial results. + fake_server.set_request_handler::( + move |_, _| async move { + future::pending::<()>().await; + Err(anyhow::anyhow!("should never respond")) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + + // The waiter must be released after the request times out, instead of + // being held through every retry of the background refresh loop. + cx.executor().advance_clock(DEFAULT_LSP_REQUEST_TIMEOUT * 2); + let refreshed = pull_task.await; + assert!( + !refreshed, + "pull should report a failed refresh when the server never responds" + ); +} + #[gpui::test] async fn test_edits_from_lsp2_with_past_version(cx: &mut gpui::TestAppContext) { init_test(cx); From 6d66d5749deea76b756ececa3602d3c13f411e4f Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 17 Jul 2026 10:57:46 +0200 Subject: [PATCH 11/16] git_graph: Fix commit message overflowing into changed files section (#61177) This regressed in https://github.com/zed-industries/zed/pull/59674 Closes FR-125 Release Notes: - Fixed an issue where the commit message in the git panel could overlap other content --- crates/git_ui/src/git_graph.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index f0e4151d8e0..cd3c751bcfe 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -3733,12 +3733,12 @@ impl GitGraph { .child( div() .relative() - .size_full() + .w_full() .child( div() .id("commit-message") .text_sm() - .size_full() + .w_full() .max_h(line_height * 12.) .overflow_y_scroll() .track_scroll(scroll_handle) From dde45ff09276331eb58419c3245d4a3ccb7534f6 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 17 Jul 2026 12:01:06 +0300 Subject: [PATCH 12/16] Mimic macOS bundling behavior for Windows builds (#61180) Rei-mplements the logic from https://github.com/zed-industries/zed/blob/9552acc2bc242d45342fa9b5a987d43868aee1ec/script/bundle-mac#L120-L123 for Windows bundling so that `run-bundling` label in CI can build unsigned Windows binaries properly. Release Notes: - N/A --- crates/zed/resources/windows/zed.iss | 2 +- script/bundle-windows.ps1 | 38 ++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/zed/resources/windows/zed.iss b/crates/zed/resources/windows/zed.iss index 9df6d3b2282..b0481daa6a1 100644 --- a/crates/zed/resources/windows/zed.iss +++ b/crates/zed/resources/windows/zed.iss @@ -31,7 +31,7 @@ WizardStyle=modern CloseApplications=force -#if GetEnv("CI") != "" +#if GetEnv("ZED_SIGN_BUNDLE") != "" SignTool=Defaultsign #endif diff --git a/script/bundle-windows.ps1 b/script/bundle-windows.ps1 index 53527e1b8f9..99b2858f8e6 100644 --- a/script/bundle-windows.ps1 +++ b/script/bundle-windows.ps1 @@ -13,6 +13,7 @@ $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $buildSuccess = $false +$canCodeSign = $false $OSArchitecture = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) { "X64" { "x86_64" } @@ -66,18 +67,31 @@ function CheckEnvironmentVariables { return } - $requiredVars = @( - 'ZED_WORKSPACE', 'RELEASE_VERSION', 'ZED_RELEASE_CHANNEL', + $requiredVars = @('ZED_WORKSPACE', 'RELEASE_VERSION', 'ZED_RELEASE_CHANNEL') + + foreach ($var in $requiredVars) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($var))) { + Write-Error "$var is not set" + exit 1 + } + } + + # On PRs from forks the signing secrets are not populated, + # so skip code signing instead of failing, like bundle-mac does. + $signingVars = @( 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET', 'ACCOUNT_NAME', 'CERT_PROFILE_NAME', 'ENDPOINT', 'FILE_DIGEST', 'TIMESTAMP_DIGEST', 'TIMESTAMP_SERVER' ) - foreach ($var in $requiredVars) { - if (-not (Test-Path "env:$var")) { - Write-Error "$var is not set" - exit 1 - } + $missingVars = @($signingVars | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }) + if ($missingVars.Count -eq 0) { + $script:canCodeSign = $true + } else { + Write-Host "====== WARNING ======" + Write-Host "One or more of the following variables are missing: $($missingVars -join ', ')" + Write-Host "This bundle will not be code signed" + Write-Host "====== WARNING ======" } } @@ -128,7 +142,7 @@ function BuildRemoteServer { # Create zipped remote server binary $remoteServerSrc = (Resolve-Path ".\$CargoOutDir\remote_server.exe").Path - if ($env:CI) { + if ($canCodeSign) { Write-Output "Code signing remote_server.exe" & "$innoDir\sign.ps1" $remoteServerSrc } @@ -159,7 +173,7 @@ function UploadToSentry { Write-Output "install with: 'winget install -e --id=Sentry.sentry-cli'" return } - if (-not (Test-Path "env:SENTRY_AUTH_TOKEN")) { + if ([string]::IsNullOrWhiteSpace($env:SENTRY_AUTH_TOKEN)) { Write-Output "missing SENTRY_AUTH_TOKEN. skipping sentry upload." return } @@ -200,7 +214,7 @@ function MakeAppx { } function SignZedAndItsFriends { - if (-not $env:CI) { + if (-not $canCodeSign) { return } @@ -340,7 +354,9 @@ function BuildInstaller { } $innoArgs = @($issFilePath) + $defs - if($env:CI) { + if($canCodeSign) { + # Checked by zed.iss to decide whether to sign the installer. + $env:ZED_SIGN_BUNDLE = "1" $signTool = "powershell.exe -ExecutionPolicy Bypass -File $innoDir\sign.ps1 `$f" $innoArgs += "/sDefaultsign=`"$signTool`"" } From 31ceaf7908ef6239dbf5fcf74e2d185a89152ebf Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 17 Jul 2026 12:45:16 +0300 Subject: [PATCH 13/16] Bump taffy (#61183) Follow-up to https://github.com/zed-industries/zed/pull/60721 Release Notes: - N/A --- Cargo.lock | 4 ++-- crates/gpui/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2303a839d8f..1094fced2cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18274,9 +18274,9 @@ dependencies = [ [[package]] name = "taffy" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73afc801dd6bd47529eaa7c7e90557f107527d1b7c9c7ed7d7803c7b8d0c357f" +checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4" dependencies = [ "arrayvec", "grid", diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 63e1814b505..7adeeffa50d 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -91,7 +91,7 @@ async-channel.workspace = true stacksafe.workspace = true strum.workspace = true sum_tree.workspace = true -taffy = "=0.12.1" +taffy = "=0.12.2" thiserror.workspace = true gpui_util.workspace = true hdrhistogram = { workspace = true, optional = true } From fd013f7f560dbf1d2738ecc071c796dec4117984 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:32:07 -0300 Subject: [PATCH 14/16] git_graph: Remove commit preview on row hover (#61191) This PR removes the commit preview popover when hovering over rows in the git graph. In my humble opinion, I've been finding that having the hover preview damages the usability/readability of the graph. I can't properly read each row's content and understand its place on the graph if everywhere I rest my cursor in a popover appears... Moreover, almost all of the information we display in the commit preview is already displayed in the row itsself, and if you'd like to drill down into the commit description and whatnot, clicking the row opens the commit drawer and gives you that and even more information (e.g., set of changed files, etc.). Release Notes: - N/A --- crates/git_ui/src/git_graph.rs | 57 +--------------------------------- 1 file changed, 1 insertion(+), 56 deletions(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index cd3c751bcfe..6e1ddd12de3 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1,7 +1,7 @@ pub use crate::commit_context_menu::{CopyCommitSha, CopyCommitTag, OpenCommitView}; use crate::{ commit_context_menu::{CommitContextMenuData, CommitContextMenuSource, commit_context_menu}, - commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, + commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon, }; @@ -10,7 +10,6 @@ use editor::Editor; use file_icons::FileIcons; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, - commit::ParsedCommitMessage, parse_git_remote_url, repository::{ CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, @@ -1694,10 +1693,6 @@ impl GitGraph { git_store.repositories().get(&self.repo_id).cloned() } - fn has_context_menu(&self) -> bool { - self.context_menu.is_some() - } - /// Checks whether a ref name from git's `%D` decoration /// format refers to the currently checked-out branch. fn is_head_ref(ref_name: &str, head_branch_name: &Option) -> bool { @@ -1791,7 +1786,6 @@ impl GitGraph { }); let row_height = Self::row_height(window, cx); - let has_context_menu = self.has_context_menu(); // We fetch data outside the visible viewport to avoid loading entries when // users scroll through the git graph @@ -1899,55 +1893,6 @@ impl GitGraph { div() .id(ElementId::NamedInteger("commit-subject".into(), idx as u64)) .overflow_hidden() - .when(!has_context_menu, |this| { - if let CommitDataState::Loaded(commit_data) = &data { - let sha = commit.data.sha.to_string(); - let author_name = commit_data.author_name.clone(); - let author_email = commit_data.author_email.clone(); - let message = commit_data.message.clone(); - let commit_timestamp = commit_data.commit_timestamp; - let tag_names = commit - .data - .tag_names() - .into_iter() - .map(SharedString::from) - .collect::>(); - let workspace = self.workspace.clone(); - let repository = repository.clone(); - this.hoverable_tooltip(move |_window, cx| { - let remote_url = repository.read(cx).default_remote_url(); - let provider_registry = - GitHostingProviderRegistry::default_global(cx); - let commit_details = CommitDetails { - sha: sha.clone().into(), - author_name: author_name.clone(), - author_email: author_email.clone(), - commit_time: OffsetDateTime::from_unix_timestamp( - commit_timestamp, - ) - .unwrap_or_else(|_| OffsetDateTime::now_utc()), - message: Some(ParsedCommitMessage::parse( - sha.clone(), - message.to_string(), - remote_url.as_deref(), - Some(provider_registry), - )), - tag_names: tag_names.clone(), - }; - cx.new(|cx| { - CommitTooltip::new( - commit_details, - repository.clone(), - workspace.clone(), - cx, - ) - }) - .into() - }) - } else { - this - } - }) .child( h_flex() .gap_2() From df9321ec8167e593092e1f0bf3847b463e5c868c Mon Sep 17 00:00:00 2001 From: Ibrahim Khan <2005ibrahimkhan@gmail.com> Date: Fri, 17 Jul 2026 04:36:37 -0700 Subject: [PATCH 15/16] agent_ui: Fix project rule pluralization in context usage popover (#61184) The agent panel's context usage popover showed "1 project rules" when exactly one project rule (e.g. `.rules`) was loaded, because the project-rules button used an unconditional `format!("{} project rules", ...)`. Reuse the existing `pluralize` helper (already used for diagnostics labels in the same crate) so a single rule renders as "1 project rule" and multiple rules render as "N project rules". The sibling "1 global rule" label is intentionally left unchanged, since there is only ever one global rule. Closes #57759 Release Notes: - Fixed the agent panel context usage popover showing "1 project rules" instead of "1 project rule" when a single project rule is loaded. --- crates/agent_ui/src/completion_provider.rs | 2 +- crates/agent_ui/src/conversation_view/thread_view.rs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index ccc95c2fc4e..ff6f4ca13b1 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -2223,7 +2223,7 @@ fn diagnostics_crease_label( diagnostics_label(summary, include_errors, include_warnings).into() } -fn pluralize(noun: &str, count: usize) -> String { +pub(crate) fn pluralize(noun: &str, count: usize) -> String { if count == 1 { noun.to_string() } else { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 0979b9e26bf..290bebd5915 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -22,7 +22,7 @@ use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCo use editor::actions::OpenExcerpts; use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; -use crate::completion_provider::{AvailableSkill, PromptLocalCommand}; +use crate::completion_provider::{AvailableSkill, PromptLocalCommand, pluralize}; use crate::message_editor::SharedSessionCapabilities; use crate::ui::{ SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip, TerminalSandboxWarning, @@ -5780,8 +5780,12 @@ impl Render for TokenUsageTooltip { Button::new( "open-project-rules", format!( - "{} project rules", - project_rules_count + "{} {}", + project_rules_count, + pluralize( + "project rule", + project_rules_count + ) ), ) .end_icon( From 1d2a4b3f7f194184dccadfa7a091d16a06482752 Mon Sep 17 00:00:00 2001 From: d0 <35865489+neiii@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:41:08 +0100 Subject: [PATCH 16/16] outline: Add preview pane to buffer symbols (#61069) # Objective - Implement preview pane for buffer symbols, similar to #59863 ## Showcase ### Before CleanShot 2026-07-15 at 23 25
19@2x ### After CleanShot 2026-07-15 at 23 19
09@2x --- Release Notes: - Added a preview pane to the buffer symbols picker --- Cargo.lock | 1 + crates/outline/Cargo.toml | 1 + crates/outline/src/outline.rs | 37 ++++++++++++++++++++++++++++++++--- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1094fced2cf..3775ab6b3a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12560,6 +12560,7 @@ dependencies = [ "lsp", "menu", "picker", + "picker_preview", "project", "rope", "serde_json", diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index 8b49b5a2870..ba661b36f98 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -18,6 +18,7 @@ fuzzy_nucleo.workspace = true gpui.workspace = true language.workspace = true picker.workspace = true +picker_preview.workspace = true settings.workspace = true theme.workspace = true theme_settings.workspace = true diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index ffd11e3197c..0f22226f89a 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -10,8 +10,8 @@ use gpui::{ ParentElement, Point, Rems, Render, Styled, StyledText, Task, TextStyle, WeakEntity, Window, div, rems, }; -use language::{Outline, OutlineItem, OutlineSearchEntry}; -use picker::{Picker, PickerDelegate}; +use language::{OffsetRangeExt, Outline, OutlineItem, OutlineSearchEntry}; +use picker::{MatchLocation, Picker, PickerDelegate, PreviewUpdate}; use settings::Settings; use theme::ActiveTheme; use theme_settings::ThemeSettings; @@ -176,9 +176,16 @@ impl OutlineView { window: &mut Window, cx: &mut Context, ) -> OutlineView { + let project = editor.read(cx).project().cloned(); let delegate = OutlineViewDelegate::new(cx.entity().downgrade(), outline, editor, cx); let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) + let picker = if let Some(project) = project { + let preview = picker_preview::editor_preview(project, window, cx); + Picker::uniform_list_with_preview(delegate, preview, window, cx) + } else { + Picker::uniform_list(delegate, window, cx) + }; + picker .max_height(Rems::from_pixels( window.viewport_size().height * 0.75, window, @@ -293,6 +300,30 @@ impl PickerDelegate for OutlineViewDelegate { self.set_selected_index(ix, true, cx); } + fn try_get_preview_data_for_match(&self, cx: &App) -> Option { + let selected_match = self.matches.get(self.selected_match_index)?; + let outline_item = self.outline.items.get(selected_match.candidate_id())?; + let multi_buffer = self.active_editor.read(cx).buffer().clone(); + let (buffer, start) = multi_buffer + .read(cx) + .text_anchor_for_position(outline_item.selection_range.start, cx)?; + let (end_buffer, end) = multi_buffer + .read(cx) + .text_anchor_for_position(outline_item.selection_range.end, cx)?; + if buffer != end_buffer { + return None; + } + + let range = (start..end).to_offset(&buffer.read(cx).text_snapshot()); + Some(PreviewUpdate::from_buffer( + buffer, + MatchLocation { + anchor_range: start..end, + range, + }, + )) + } + fn update_matches( &mut self, query: String,