csv_preview: Add independently resizable columns (#53295)

Oleksandr Kholiavko and Anthony Eid created

This PR adds spreadsheet-style independently resizable columns (dragging
changes total table width) and fixes scrolling issues in variable list
mode.

**What changed:**

- Adds `ResizableColumnsState` struct for independently resizable
columns (spreadsheet-style)
- Adds `ColumnWidthConfig::Resizable` variant for spreadsheet mode
- Adds `DraggedResizableColumn` drag payload type
- Adds `ResizableHeaderInfo` for double-click-to-reset functionality
- Adds `render_resize_handles_resizable` function for resize handles
rendering
- Adds horizontal scroll handle to `TableInteractionState`
- Adds `.on_drag_move::<DraggedResizableColumn>` handler to Table for
drag resizing

**Bug fixes:**

- Fixed missing vertical scrollbar in variable list mode
- Fixed half-broken scrolling in variable list mode (added
.measure_all() to make scrollbar aware of the height of the table)
- Moved vertical scrollbar to be pinned at the right side of the pane
with table — previously it was attached to the table content and was
pushed off-screen when table content was too wide

**API addition:**

```rust
// New variant added:
pub enum ColumnWidthConfig {
    Static { widths: StaticColumnWidths, table_width: Option<DefiniteLength> },
    Redistributable { entity: Entity<RedistributableColumnsState>, table_width: Option<DefiniteLength> },
    Resizable(Entity<ResizableColumnsState>),  // NEW: spreadsheet-style
}
```

**Callers updated:**

- csv_preview: Changed from `ColumnWidthConfig::redistributable()` to
use new resizable mode
- git_graph: Added `resizable_info` parameter

**Context:**

This is part 3 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. [#51120](https://github.com/zed-industries/zed/pull/51120) -
Introduce width config enum for redistributable column widths (API
rework)
3. **This PR**: Add independently resizable columns + fix variable list
scrolling (new feature + bug fixes)

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))

This work is based on the [original draft PR
#44344](https://github.com/zed-industries/zed/pull/44344), decomposed
into reviewable pieces.

-----

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #ISSUE

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>

Change summary

crates/csv_preview/src/csv_preview.rs               |  38 
crates/csv_preview/src/renderer/render_table.rs     |  12 
crates/csv_preview/src/settings.rs                  |   8 
crates/git_graph/src/git_graph.rs                   |   3 
crates/ui/src/components/data_table.rs              | 359 ++++++++++++--
crates/ui/src/components/redistributable_columns.rs | 232 ++++++--
6 files changed, 483 insertions(+), 169 deletions(-)

Detailed changes

crates/csv_preview/src/csv_preview.rs 🔗

@@ -10,8 +10,8 @@ use std::{
 
 use crate::table_data_engine::TableDataEngine;
 use ui::{
-    AbsoluteLength, DefiniteLength, RedistributableColumnsState, SharedString,
-    TableInteractionState, TableResizeBehavior, prelude::*,
+    AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState,
+    TableResizeBehavior, prelude::*,
 };
 use workspace::{Item, SplitDirection, Workspace};
 
@@ -56,27 +56,25 @@ pub fn init(cx: &mut App) {
 
 impl CsvPreviewView {
     pub(crate) fn sync_column_widths(&self, cx: &mut Context<Self>) {
-        // plus 1 for the rows column
+        // plus 1 for the row identifier 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 widths: Vec<AbsoluteLength> = vec![AbsoluteLength::Pixels(px(150.)); cols];
+        widths[0] = AbsoluteLength::Pixels(px(line_number_width));
 
         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);
+            if state.cols() != cols {
+                *state = ResizableColumnsState::new(cols, widths, resize_behaviors);
+            } else {
+                state.set_column_configuration(
+                    0,
+                    AbsoluteLength::Pixels(px(line_number_width)),
+                    TableResizeBehavior::None,
+                );
             }
         });
     }
@@ -207,7 +205,7 @@ impl CsvPreviewView {
 
         // Update list state with filtered row count
         let visible_rows = self.engine.d2d_mapping().visible_row_count();
-        self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(1.));
+        self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.));
     }
 
     pub fn resolve_active_item_as_csv_editor(
@@ -313,16 +311,16 @@ impl PerformanceMetrics {
 
 /// Holds state of column widths for a table component in CSV preview.
 pub(crate) struct ColumnWidths {
-    pub widths: Entity<RedistributableColumnsState>,
+    pub widths: Entity<ResizableColumnsState>,
 }
 
 impl ColumnWidths {
     pub(crate) fn new(cx: &mut Context<CsvPreviewView>, cols: usize) -> Self {
         Self {
             widths: cx.new(|_cx| {
-                RedistributableColumnsState::new(
+                ResizableColumnsState::new(
                     cols,
-                    vec![ui::DefiniteLength::Fraction(1.0 / cols as f32); cols],
+                    vec![AbsoluteLength::Pixels(px(150.)); cols],
                     vec![ui::TableResizeBehavior::Resizable; cols],
                 )
             }),

crates/csv_preview/src/renderer/render_table.rs 🔗

@@ -1,9 +1,7 @@
 use crate::types::TableCell;
 use gpui::{AnyElement, Entity};
 use std::ops::Range;
-use ui::{
-    ColumnWidthConfig, RedistributableColumnsState, Table, UncheckedTableRow, div, prelude::*,
-};
+use ui::{ColumnWidthConfig, ResizableColumnsState, Table, UncheckedTableRow, div, prelude::*};
 
 use crate::{
     CsvPreviewView,
@@ -13,10 +11,10 @@ use crate::{
 
 impl CsvPreviewView {
     /// Creates a new table.
-    /// Column number is derived from the `RedistributableColumnsState` entity.
+    /// Column number is derived from the `ResizableColumnsState` entity.
     pub(crate) fn create_table(
         &self,
-        current_widths: &Entity<RedistributableColumnsState>,
+        current_widths: &Entity<ResizableColumnsState>,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         self.create_table_inner(self.engine.contents.rows.len(), current_widths, cx)
@@ -25,7 +23,7 @@ impl CsvPreviewView {
     fn create_table_inner(
         &self,
         row_count: usize,
-        current_widths: &Entity<RedistributableColumnsState>,
+        current_widths: &Entity<ResizableColumnsState>,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let cols = current_widths.read(cx).cols();
@@ -54,7 +52,7 @@ impl CsvPreviewView {
         Table::new(cols)
             .interactable(&self.table_interaction_state)
             .striped()
-            .width_config(ColumnWidthConfig::redistributable(current_widths.clone()))
+            .width_config(ColumnWidthConfig::Resizable(current_widths.clone()))
             .header(headers)
             .disable_base_style()
             .map(|table| {

crates/csv_preview/src/settings.rs 🔗

@@ -1,10 +1,10 @@
 #[derive(Default, Clone, Copy)]
 pub enum RowRenderMechanism {
-    /// Default behaviour
-    #[default]
-    VariableList,
-    /// More performance oriented, but all rows are same height
+    /// More correct for multiline content, but slower.
     #[allow(dead_code)] // Will be used when settings ui is added
+    VariableList,
+    /// Default behaviour for now while resizable columns are being stabilized.
+    #[default]
     UniformList,
 }
 

crates/git_graph/src/git_graph.rs 🔗

@@ -2564,7 +2564,8 @@ impl Render for GitGraph {
                     this.child(self.render_loading_spinner(cx))
                 })
         } else {
-            let header_resize_info = HeaderResizeInfo::from_state(&self.column_widths, cx);
+            let header_resize_info =
+                HeaderResizeInfo::from_redistributable(&self.column_widths, cx);
             let header_context = TableRenderContext::for_column_widths(
                 Some(self.column_widths.read(cx).widths_to_render()),
                 true,

crates/ui/src/components/data_table.rs 🔗

@@ -1,22 +1,22 @@
 use std::{ops::Range, rc::Rc};
 
-use gpui::{
-    DefiniteLength, Entity, EntityId, FocusHandle, Length, ListHorizontalSizingBehavior,
-    ListSizingBehavior, ListState, Point, Stateful, UniformListScrollHandle, WeakEntity, list,
-    transparent_black, uniform_list,
-};
-
 use crate::{
     ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
-    ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo,
-    Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RedistributableColumnsState,
-    RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString,
-    StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, WithScrollbar,
-    bind_redistributable_columns, div, example_group_with_title, h_flex, px,
+    ComponentScope, Context, Div, DraggedColumn, ElementId, FixedWidth as _, FluentBuilder as _,
+    HeaderResizeInfo, Indicator, InteractiveElement, IntoElement, ParentElement, Pixels,
+    RESIZE_DIVIDER_WIDTH, RedistributableColumnsState, RegisterComponent, RenderOnce, ScrollAxes,
+    ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, StyledExt as _,
+    StyledTypography, TableResizeBehavior, Window, WithScrollbar, bind_redistributable_columns,
+    div, example_group_with_title, h_flex, px, render_column_resize_divider,
     render_redistributable_columns_resize_handles, single_example,
     table_row::{IntoTableRow as _, TableRow},
     v_flex,
 };
+use gpui::{
+    AbsoluteLength, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle, Length,
+    ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, ScrollHandle, Stateful,
+    UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list,
+};
 
 pub mod table_row;
 #[cfg(test)]
@@ -26,6 +26,96 @@ mod tests;
 /// Will be converted into `TableRow<T>` internally
 pub type UncheckedTableRow<T> = Vec<T>;
 
+/// State for independently resizable columns (spreadsheet-style).
+///
+/// Each column has its own absolute width; dragging a resize handle changes only
+/// that column's width, growing or shrinking the overall table width.
+pub struct ResizableColumnsState {
+    initial_widths: TableRow<AbsoluteLength>,
+    widths: TableRow<AbsoluteLength>,
+    resize_behavior: TableRow<TableResizeBehavior>,
+}
+
+impl ResizableColumnsState {
+    pub fn new(
+        cols: usize,
+        initial_widths: Vec<impl Into<AbsoluteLength>>,
+        resize_behavior: Vec<TableResizeBehavior>,
+    ) -> Self {
+        let widths: TableRow<AbsoluteLength> = initial_widths
+            .into_iter()
+            .map(Into::into)
+            .collect::<Vec<_>>()
+            .into_table_row(cols);
+        Self {
+            initial_widths: widths.clone(),
+            widths,
+            resize_behavior: resize_behavior.into_table_row(cols),
+        }
+    }
+
+    pub fn cols(&self) -> usize {
+        self.widths.cols()
+    }
+
+    pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
+        &self.resize_behavior
+    }
+
+    pub(crate) fn on_drag_move(
+        &mut self,
+        drag_event: &DragMoveEvent<DraggedColumn>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let col_idx = drag_event.drag(cx).col_idx;
+        let rem_size = window.rem_size();
+        let drag_x = drag_event.event.position.x - drag_event.bounds.left();
+
+        let left_edge: Pixels = self.widths.as_slice()[..col_idx]
+            .iter()
+            .map(|width| width.to_pixels(rem_size))
+            .fold(px(0.), |acc, x| acc + x);
+
+        let new_width = drag_x - left_edge;
+        let new_width = self.apply_min_size(new_width, self.resize_behavior[col_idx], rem_size);
+
+        self.widths[col_idx] = AbsoluteLength::Pixels(new_width);
+        cx.notify();
+    }
+
+    pub fn set_column_configuration(
+        &mut self,
+        col_idx: usize,
+        width: impl Into<AbsoluteLength>,
+        resize_behavior: TableResizeBehavior,
+    ) {
+        let width = width.into();
+        self.initial_widths[col_idx] = width;
+        self.widths[col_idx] = width;
+        self.resize_behavior[col_idx] = resize_behavior;
+    }
+
+    pub fn reset_column_to_initial_width(&mut self, col_idx: usize) {
+        self.widths[col_idx] = self.initial_widths[col_idx];
+    }
+
+    fn apply_min_size(
+        &self,
+        width: Pixels,
+        behavior: TableResizeBehavior,
+        rem_size: Pixels,
+    ) -> Pixels {
+        match behavior.min_size() {
+            Some(min_rems) => {
+                let min_px = rem_size * min_rems;
+                width.max(min_px)
+            }
+            None => width,
+        }
+    }
+}
+
 struct UniformListData {
     render_list_of_rows_fn:
         Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<UncheckedTableRow<AnyElement>>>,
@@ -71,6 +161,7 @@ impl TableContents {
 pub struct TableInteractionState {
     pub focus_handle: FocusHandle,
     pub scroll_handle: UniformListScrollHandle,
+    pub horizontal_scroll_handle: ScrollHandle,
     pub custom_scrollbar: Option<Scrollbars>,
 }
 
@@ -79,6 +170,7 @@ impl TableInteractionState {
         Self {
             focus_handle: cx.focus_handle(),
             scroll_handle: UniformListScrollHandle::new(),
+            horizontal_scroll_handle: ScrollHandle::new(),
             custom_scrollbar: None,
         }
     }
@@ -120,6 +212,9 @@ pub enum ColumnWidthConfig {
         columns_state: Entity<RedistributableColumnsState>,
         table_width: Option<DefiniteLength>,
     },
+    /// Independently resizable columns — dragging changes absolute column widths
+    /// and thus the overall table width. Like spreadsheets.
+    Resizable(Entity<ResizableColumnsState>),
 }
 
 pub enum StaticColumnWidths {
@@ -184,24 +279,52 @@ impl ColumnWidthConfig {
                 columns_state: entity,
                 ..
             } => Some(entity.read(cx).widths_to_render()),
+            ColumnWidthConfig::Resizable(entity) => {
+                let state = entity.read(cx);
+                Some(
+                    state
+                        .widths
+                        .map_cloned(|abs| Length::Definite(DefiniteLength::Absolute(abs))),
+                )
+            }
         }
     }
 
     /// Table-level width.
-    pub fn table_width(&self) -> Option<Length> {
+    pub fn table_width(&self, window: &Window, cx: &App) -> Option<Length> {
         match self {
             ColumnWidthConfig::Static { table_width, .. }
             | ColumnWidthConfig::Redistributable { table_width, .. } => {
                 table_width.map(Length::Definite)
             }
+            ColumnWidthConfig::Resizable(entity) => {
+                let state = entity.read(cx);
+                let rem_size = window.rem_size();
+                let total: Pixels = state
+                    .widths
+                    .as_slice()
+                    .iter()
+                    .map(|abs| abs.to_pixels(rem_size))
+                    .fold(px(0.), |acc, x| acc + x);
+                Some(Length::Definite(DefiniteLength::Absolute(
+                    AbsoluteLength::Pixels(total),
+                )))
+            }
         }
     }
 
     /// ListHorizontalSizingBehavior for uniform_list.
-    pub fn list_horizontal_sizing(&self) -> ListHorizontalSizingBehavior {
-        match self.table_width() {
-            Some(_) => ListHorizontalSizingBehavior::Unconstrained,
-            None => ListHorizontalSizingBehavior::FitList,
+    pub fn list_horizontal_sizing(
+        &self,
+        window: &Window,
+        cx: &App,
+    ) -> ListHorizontalSizingBehavior {
+        match self {
+            ColumnWidthConfig::Resizable(_) => ListHorizontalSizingBehavior::FitList,
+            _ => match self.table_width(window, cx) {
+                Some(_) => ListHorizontalSizingBehavior::Unconstrained,
+                None => ListHorizontalSizingBehavior::FitList,
+            },
         }
     }
 }
@@ -515,13 +638,7 @@ pub fn render_table_header(
                             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.reset_column_to_initial_width(
-                                                    header_idx, window,
-                                                );
-                                            })
-                                            .ok();
+                                        info.reset_column(header_idx, window, cx);
                                     }
                                 })
                             } else {
@@ -572,6 +689,68 @@ impl TableRenderContext {
     }
 }
 
+fn render_resize_handles_resizable(
+    columns_state: &Entity<ResizableColumnsState>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let (widths, resize_behavior) = {
+        let state = columns_state.read(cx);
+        (state.widths.clone(), state.resize_behavior.clone())
+    };
+
+    let rem_size = window.rem_size();
+    let resize_behavior = Rc::new(resize_behavior);
+    let n_cols = widths.cols();
+    let mut dividers: Vec<AnyElement> = Vec::with_capacity(n_cols);
+    let mut accumulated_px = px(0.);
+
+    for col_idx in 0..n_cols {
+        let col_width_px = widths[col_idx].to_pixels(rem_size);
+        accumulated_px = accumulated_px + col_width_px;
+
+        // Add a resize divider after every column, including the last.
+        // For the last column the divider is pulled 1px inward so it isn't clipped
+        // by the overflow_hidden content container.
+        {
+            let divider_left = if col_idx + 1 == n_cols {
+                accumulated_px - px(RESIZE_DIVIDER_WIDTH)
+            } else {
+                accumulated_px
+            };
+            let divider = div().id(col_idx).absolute().top_0().left(divider_left);
+            let entity_id = columns_state.entity_id();
+            let on_reset: Rc<dyn Fn(&mut Window, &mut App)> = {
+                let columns_state = columns_state.clone();
+                Rc::new(move |_window, cx| {
+                    columns_state.update(cx, |state, cx| {
+                        state.reset_column_to_initial_width(col_idx);
+                        cx.notify();
+                    });
+                })
+            };
+            dividers.push(render_column_resize_divider(
+                divider,
+                col_idx,
+                resize_behavior[col_idx].is_resizable(),
+                entity_id,
+                on_reset,
+                None,
+                window,
+                cx,
+            ));
+        }
+    }
+
+    div()
+        .id("resize-handles")
+        .absolute()
+        .inset_0()
+        .w_full()
+        .children(dividers)
+        .into_any_element()
+}
+
 impl RenderOnce for Table {
     fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
         let table_context = TableRenderContext::new(&self, cx);
@@ -582,36 +761,47 @@ impl RenderOnce for Table {
                 .as_ref()
                 .and_then(|_| match &self.column_width_config {
                     ColumnWidthConfig::Redistributable { columns_state, .. } => {
-                        Some(HeaderResizeInfo::from_state(columns_state, cx))
+                        Some(HeaderResizeInfo::from_redistributable(columns_state, cx))
+                    }
+                    ColumnWidthConfig::Resizable(entity) => {
+                        Some(HeaderResizeInfo::from_resizable(entity, cx))
                     }
                     _ => None,
                 });
 
-        let table_width = self.column_width_config.table_width();
-        let horizontal_sizing = self.column_width_config.list_horizontal_sizing();
+        let table_width = self.column_width_config.table_width(window, cx);
+        let horizontal_sizing = self.column_width_config.list_horizontal_sizing(window, cx);
         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.clone()),
-                    _ => None,
-                });
-
-        let resize_handles =
-            interaction_state
-                .as_ref()
-                .and_then(|_| match &self.column_width_config {
-                    ColumnWidthConfig::Redistributable { columns_state, .. } => Some(
-                        render_redistributable_columns_resize_handles(columns_state, window, cx),
+        let variable_list_state = if let TableContents::VariableRowHeightList(data) = &self.rows {
+            Some(data.list_state.clone())
+        } else {
+            None
+        };
+
+        let (redistributable_entity, resizable_entity, resize_handles) =
+            if let Some(_) = interaction_state.as_ref() {
+                match &self.column_width_config {
+                    ColumnWidthConfig::Redistributable { columns_state, .. } => (
+                        Some(columns_state.clone()),
+                        None,
+                        Some(render_redistributable_columns_resize_handles(
+                            columns_state,
+                            window,
+                            cx,
+                        )),
                     ),
-                    _ => None,
-                });
+                    ColumnWidthConfig::Resizable(entity) => (
+                        None,
+                        Some(entity.clone()),
+                        Some(render_resize_handles_resizable(entity, window, cx)),
+                    ),
+                    _ => (None, None, None),
+                }
+            } else {
+                (None, None, None)
+            };
+
+        let is_resizable = resizable_entity.is_some();
 
         let table = div()
             .when_some(table_width, |this, width| this.w(width))
@@ -629,6 +819,14 @@ impl RenderOnce for Table {
             .when_some(redistributable_entity, |this, widths| {
                 bind_redistributable_columns(this, widths)
             })
+            .when_some(resizable_entity, |this, entity| {
+                this.on_drag_move::<DraggedColumn>(move |event, window, cx| {
+                    if event.drag(cx).state_id != entity.entity_id() {
+                        return;
+                    }
+                    entity.update(cx, |state, cx| state.on_drag_move(event, window, cx));
+                })
+            })
             .child({
                 let content = div()
                     .flex_grow()
@@ -709,22 +907,7 @@ impl RenderOnce for Table {
                     })
                     .when_some(resize_handles, |parent, handles| parent.child(handles));
 
-                if let Some(state) = interaction_state.as_ref() {
-                    let scrollbars = state
-                        .read(cx)
-                        .custom_scrollbar
-                        .clone()
-                        .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both));
-                    content
-                        .custom_scrollbars(
-                            scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle),
-                            window,
-                            cx,
-                        )
-                        .into_any_element()
-                } else {
-                    content.into_any_element()
-                }
+                content.into_any_element()
             })
             .when_some(
                 no_rows_rendered
@@ -742,10 +925,52 @@ impl RenderOnce for Table {
                 },
             );
 
-        if let Some(interaction_state) = interaction_state.as_ref() {
-            table
-                .track_focus(&interaction_state.read(cx).focus_handle)
-                .id(("table", interaction_state.entity_id()))
+        if let Some(state) = interaction_state.as_ref() {
+            // Resizable mode: wrap table in a horizontal scroll container first
+            let content = if is_resizable {
+                let mut h_scroll_container = div()
+                    .id("table-h-scroll")
+                    .overflow_x_scroll()
+                    .flex_grow()
+                    .h_full()
+                    .track_scroll(&state.read(cx).horizontal_scroll_handle)
+                    .child(table);
+                h_scroll_container.style().restrict_scroll_to_axis = Some(true);
+                div().size_full().child(h_scroll_container)
+            } else {
+                table
+            };
+
+            // Attach vertical scrollbars (converts Div → Stateful<Div>)
+            let scrollbars = state
+                .read(cx)
+                .custom_scrollbar
+                .clone()
+                .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both));
+            let mut content = if let Some(list_state) = variable_list_state {
+                content.custom_scrollbars(scrollbars.tracked_scroll_handle(&list_state), window, cx)
+            } else {
+                content.custom_scrollbars(
+                    scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle),
+                    window,
+                    cx,
+                )
+            };
+
+            // Add horizontal scrollbar when in resizable mode
+            if is_resizable {
+                content = content.custom_scrollbars(
+                    Scrollbars::new(ScrollAxes::Horizontal)
+                        .tracked_scroll_handle(&state.read(cx).horizontal_scroll_handle),
+                    window,
+                    cx,
+                );
+            }
+            content.style().restrict_scroll_to_axis = Some(true);
+
+            content
+                .track_focus(&state.read(cx).focus_handle)
+                .id(("table", state.entity_id()))
                 .into_any_element()
         } else {
             table.into_any_element()

crates/ui/src/components/redistributable_columns.rs 🔗

@@ -1,23 +1,32 @@
 use std::rc::Rc;
 
 use gpui::{
-    AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, Length,
-    WeakEntity,
+    AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity,
+    EntityId, Length, Stateful, WeakEntity,
 };
 use itertools::intersperse_with;
 
-use super::data_table::table_row::{IntoTableRow as _, TableRow};
+use super::data_table::{
+    ResizableColumnsState,
+    table_row::{IntoTableRow as _, TableRow},
+};
 use crate::{
     ActiveTheme as _, AnyElement, App, Context, Div, FluentBuilder as _, InteractiveElement,
     IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, h_flex,
     px,
 };
 
-const RESIZE_COLUMN_WIDTH: f32 = 8.0;
-const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
+pub(crate) const RESIZE_COLUMN_WIDTH: f32 = 8.0;
+pub(crate) const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
 
+/// Drag payload for column resize handles.
+/// Includes the `EntityId` of the owning column state so that
+/// `on_drag_move` handlers on unrelated tables ignore the event.
 #[derive(Debug)]
-struct DraggedColumn(usize);
+pub(crate) struct DraggedColumn {
+    pub(crate) col_idx: usize,
+    pub(crate) state_id: EntityId,
+}
 
 #[derive(Debug, Copy, Clone, PartialEq)]
 pub enum TableResizeBehavior {
@@ -40,20 +49,56 @@ impl TableResizeBehavior {
     }
 }
 
+#[derive(Clone)]
+pub(crate) enum ColumnsStateRef {
+    Redistributable(WeakEntity<RedistributableColumnsState>),
+    Resizable(WeakEntity<ResizableColumnsState>),
+}
+
 #[derive(Clone)]
 pub struct HeaderResizeInfo {
-    pub columns_state: WeakEntity<RedistributableColumnsState>,
+    pub(crate) columns_state: ColumnsStateRef,
     pub resize_behavior: TableRow<TableResizeBehavior>,
 }
 
 impl HeaderResizeInfo {
-    pub fn from_state(columns_state: &Entity<RedistributableColumnsState>, cx: &App) -> Self {
+    pub fn from_redistributable(
+        columns_state: &Entity<RedistributableColumnsState>,
+        cx: &App,
+    ) -> Self {
         let resize_behavior = columns_state.read(cx).resize_behavior().clone();
         Self {
-            columns_state: columns_state.downgrade(),
+            columns_state: ColumnsStateRef::Redistributable(columns_state.downgrade()),
             resize_behavior,
         }
     }
+
+    pub fn from_resizable(columns_state: &Entity<ResizableColumnsState>, cx: &App) -> Self {
+        let resize_behavior = columns_state.read(cx).resize_behavior().clone();
+        Self {
+            columns_state: ColumnsStateRef::Resizable(columns_state.downgrade()),
+            resize_behavior,
+        }
+    }
+
+    pub fn reset_column(&self, col_idx: usize, window: &mut Window, cx: &mut App) {
+        match &self.columns_state {
+            ColumnsStateRef::Redistributable(weak) => {
+                weak.update(cx, |state, cx| {
+                    state.reset_column_to_initial_width(col_idx, window);
+                    cx.notify();
+                })
+                .ok();
+            }
+            ColumnsStateRef::Resizable(weak) => {
+                weak.update(cx, |state, cx| {
+                    state.reset_column_to_initial_width(col_idx);
+                    cx.notify();
+                })
+                .ok();
+            }
+        }
+    }
 }
 
 pub struct RedistributableColumnsState {
@@ -237,7 +282,7 @@ impl RedistributableColumnsState {
 
         let mut col_position = 0.0;
         let rem_size = window.rem_size();
-        let col_idx = drag_event.drag(cx).0;
+        let col_idx = drag_event.drag(cx).col_idx;
 
         let divider_width = Self::get_fraction(
             &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))),
@@ -348,6 +393,9 @@ pub fn bind_redistributable_columns(
         .on_drag_move::<DraggedColumn>({
             let columns_state = columns_state.clone();
             move |event, window, cx| {
+                if event.drag(cx).state_id != columns_state.entity_id() {
+                    return;
+                }
                 columns_state.update(cx, |columns, cx| {
                     columns.on_drag_move(event, window, cx);
                 });
@@ -394,67 +442,34 @@ pub fn render_redistributable_columns_resize_handles(
             let columns_state = columns_state.clone();
             column_ix += 1;
 
-            window.with_id(current_column_ix, |window| {
-                let mut resize_divider = div()
-                    .id(current_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 resize_behavior[current_column_ix].is_resizable() {
-                    let is_highlighted = window.use_state(cx, |_window, _cx| false);
-
-                    resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| {
-                        div.bg(cx.theme().colors().border_focused)
-                    });
-
-                    resize_handle = resize_handle
-                        .on_hover({
-                            let is_highlighted = is_highlighted.clone();
-                            move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered)
-                        })
-                        .cursor_col_resize()
-                        .on_click({
-                            let columns_state = columns_state.clone();
-                            move |event, window, cx| {
-                                if event.click_count() >= 2 {
-                                    columns_state.update(cx, |columns, _| {
-                                        columns.reset_column_to_initial_width(
-                                            current_column_ix,
-                                            window,
-                                        );
-                                    });
-                                }
-
-                                cx.stop_propagation();
-                            }
-                        })
-                        .on_drag(DraggedColumn(current_column_ix), {
-                            let is_highlighted = is_highlighted.clone();
-                            move |_, _offset, _window, cx| {
-                                is_highlighted.write(cx, true);
-                                cx.new(|_cx| Empty)
-                            }
-                        })
-                        .on_drop::<DraggedColumn>(move |_, _, cx| {
-                            is_highlighted.write(cx, false);
-                            columns_state.update(cx, |state, _| {
-                                state.commit_preview();
-                            });
+            {
+                let divider = div().id(current_column_ix).relative().top_0();
+                let entity_id = columns_state.entity_id();
+                let on_reset: Rc<dyn Fn(&mut Window, &mut App)> = {
+                    let columns_state = columns_state.clone();
+                    Rc::new(move |window, cx| {
+                        columns_state.update(cx, |columns, cx| {
+                            columns.reset_column_to_initial_width(current_column_ix, window);
+                            cx.notify();
                         });
-                }
-
-                resize_divider.child(resize_handle).into_any_element()
-            })
+                    })
+                };
+                let on_drag_end: Option<Rc<dyn Fn(&mut App)>> = {
+                    Some(Rc::new(move |cx| {
+                        columns_state.update(cx, |state, _| state.commit_preview());
+                    }))
+                };
+                render_column_resize_divider(
+                    divider,
+                    current_column_ix,
+                    resize_behavior[current_column_ix].is_resizable(),
+                    entity_id,
+                    on_reset,
+                    on_drag_end,
+                    window,
+                    cx,
+                )
+            }
         },
     );
 
@@ -467,6 +482,83 @@ pub fn render_redistributable_columns_resize_handles(
         .into_any_element()
 }
 
+/// Builds a single column resize divider with an interactive drag handle.
+///
+/// The caller provides:
+/// - `divider`: a pre-positioned divider element (with absolute or relative positioning)
+/// - `col_idx`: which column this divider is for
+/// - `is_resizable`: whether the column supports resizing
+/// - `entity_id`: the `EntityId` of the owning column state (for the drag payload)
+/// - `on_reset`: called on double-click to reset the column to its initial width
+/// - `on_drag_end`: called when the drag ends (e.g. to commit preview widths)
+pub(crate) fn render_column_resize_divider(
+    divider: Stateful<Div>,
+    col_idx: usize,
+    is_resizable: bool,
+    entity_id: EntityId,
+    on_reset: Rc<dyn Fn(&mut Window, &mut App)>,
+    on_drag_end: Option<Rc<dyn Fn(&mut App)>>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    window.with_id(col_idx, |window| {
+        let mut resize_divider = divider.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 is_resizable {
+            let is_highlighted = window.use_state(cx, |_window, _cx| false);
+
+            resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| {
+                div.bg(cx.theme().colors().border_focused)
+            });
+
+            resize_handle = resize_handle
+                .on_hover({
+                    let is_highlighted = is_highlighted.clone();
+                    move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered)
+                })
+                .cursor_col_resize()
+                .on_click(move |event, window, cx| {
+                    if event.click_count() >= 2 {
+                        on_reset(window, cx);
+                    }
+                    cx.stop_propagation();
+                })
+                .on_drag(
+                    DraggedColumn {
+                        col_idx,
+                        state_id: entity_id,
+                    },
+                    {
+                        let is_highlighted = is_highlighted.clone();
+                        move |_, _offset, _window, cx| {
+                            is_highlighted.write(cx, true);
+                            cx.new(|_cx| Empty)
+                        }
+                    },
+                )
+                .on_drop::<DraggedColumn>(move |_, _, cx| {
+                    is_highlighted.write(cx, false);
+                    if let Some(on_drag_end) = &on_drag_end {
+                        on_drag_end(cx);
+                    }
+                });
+        }
+
+        resize_divider.child(resize_handle).into_any_element()
+    })
+}
+
 fn resize_spacer(width: Length) -> Div {
     div().w(width).h_full()
 }