use gpui_base::StyledExt as _;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
};
use gpui::{
AnyElement, App, AppContext as _, Context, Div, EventEmitter, FocusHandle, Focusable,
InteractiveElement as _, IntoElement as _, KeyBinding, ParentElement as _, Pixels, Rems,
Render, Role, SharedString, Stateful, StatefulInteractiveElement as _, Styled as _,
Subscription, Window, div, prelude::FluentBuilder as _, rems,
};
use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, Size,
spinner::Spinner,
table::{DataTable, TableEvent, TableState},
text::TextView,
};
use crate::{
motion::Shimmer,
resolved_layout::ResolvedLayoutKey,
stream::{ProgressState, Progressive},
};
mod delegate;
mod reorder;
mod stable_id;
use delegate::RecordsDelegate;
use stable_id::{StableIdIndex, index_valid_columns, index_valid_rows};
const RECORDS_TABLE_CONTEXT: &str = "GpuiAiRecordsTable";
gpui::actions!(
gpui_ai_records_table,
[
ActivateRecord
]
);
pub(crate) fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("enter", ActivateRecord, Some(RECORDS_TABLE_CONTEXT)),
KeyBinding::new("space", ActivateRecord, Some(RECORDS_TABLE_CONTEXT)),
]);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordSortDirection {
Ascending,
Descending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RowActionVisibility {
Always,
#[default]
OnHover,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RowActionPlacement {
Inline,
#[default]
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RecordColumnAlignment {
#[default]
Left,
Center,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RecordCellKind {
Change,
#[default]
Text,
Tags,
Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordStatusTone {
Positive,
Neutral,
Caution,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecordsTableEvent {
SelectionRequested {
id: SharedString,
row_id: SharedString,
},
ActivationRequested {
id: SharedString,
row_id: SharedString,
},
SortRequested {
id: SharedString,
column_id: SharedString,
direction: Option<RecordSortDirection>,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
enum RecordColumnWidth {
#[default]
Unset,
Pixels(Pixels),
Rems(Rems),
}
impl RecordColumnWidth {
fn resolve(self, rem_size: Pixels) -> Option<Pixels> {
match self {
Self::Unset => None,
Self::Pixels(width) => Some(width),
Self::Rems(width) => Some(width.to_pixels(rem_size)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColumnFit {
#[default]
Fill,
Intrinsic,
}
fn measured_grid_width(owner: gpui::WeakEntity<RecordsTable>) -> impl gpui::IntoElement {
gpui::canvas(
move |bounds, _, cx| {
let _ = owner.update(cx, |table, cx| table.observe_width(bounds.size.width, cx));
},
|_, _, _, _| {},
)
.absolute()
.inset_0()
}
fn resolve_column_widths(
columns: &[RecordColumn],
fit: ColumnFit,
available: Option<Pixels>,
rem_size: Pixels,
) -> Arc<[Pixels]> {
let bases = columns
.iter()
.map(|column| {
column
.width
.resolve(rem_size)
.unwrap_or_else(default_column_width)
})
.collect::<Vec<_>>();
let (ColumnFit::Fill, Some(available)) = (fit, available) else {
return bases.into();
};
let asked_for = bases.iter().fold(Pixels::ZERO, |total, base| total + *base);
let surplus = available - asked_for;
if surplus <= Pixels::ZERO {
return bases.into();
}
let asked = columns.iter().any(|column| column.fill.is_some());
let shares = columns
.iter()
.zip(&bases)
.map(|(column, base)| match (asked, column.fill) {
(true, Some(share)) => share,
(true, None) => 0.,
(false, _) => f32::from(*base),
})
.collect::<Vec<_>>();
let total: f32 = shares.iter().sum();
if total <= 0. {
return bases.into();
}
bases
.iter()
.zip(&shares)
.map(|(base, share)| *base + surplus * (share / total))
.collect()
}
fn default_column_width() -> Pixels {
gpui_component::table::Column::default().width
}
#[derive(Debug, Clone, PartialEq)]
pub struct RecordColumn {
id: SharedString,
label: SharedString,
sortable: bool,
width: RecordColumnWidth,
fill: Option<f32>,
alignment: RecordColumnAlignment,
fixed: bool,
description: Option<SharedString>,
}
impl RecordColumn {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
sortable: false,
width: RecordColumnWidth::Unset,
fill: None,
alignment: RecordColumnAlignment::Left,
fixed: false,
description: None,
}
}
pub fn sortable(mut self, sortable: bool) -> Self {
self.sortable = sortable;
self
}
pub fn width(mut self, width: Pixels) -> Self {
self.width = RecordColumnWidth::Pixels(width);
self
}
pub fn width_in_rems(mut self, width: f32) -> Self {
self.width = RecordColumnWidth::Rems(rems(width));
self
}
pub fn fill(mut self, share: f32) -> Self {
self.fill = Some(share.max(0.));
self
}
pub fn alignment(mut self, alignment: RecordColumnAlignment) -> Self {
self.alignment = alignment;
self
}
pub fn fixed(mut self, fixed: bool) -> Self {
self.fixed = fixed;
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn id(&self) -> &str {
self.id.as_ref()
}
pub fn label(&self) -> &str {
self.label.as_ref()
}
pub fn is_sortable(&self) -> bool {
self.sortable
}
pub fn configured_width(&self) -> Option<Pixels> {
match self.width {
RecordColumnWidth::Pixels(width) => Some(width),
RecordColumnWidth::Unset | RecordColumnWidth::Rems(_) => None,
}
}
pub fn column_alignment(&self) -> RecordColumnAlignment {
self.alignment
}
pub fn is_fixed(&self) -> bool {
self.fixed
}
pub fn accessible_description(&self) -> Option<&str> {
self.description.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordCell {
column_id: SharedString,
value: SharedString,
kind: RecordCellKind,
tags: Arc<[SharedString]>,
status_tone: Option<RecordStatusTone>,
change_before: Option<SharedString>,
}
impl RecordCell {
pub fn new(column_id: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
Self {
column_id: column_id.into(),
value: value.into(),
kind: RecordCellKind::Text,
tags: Arc::from([]),
status_tone: None,
change_before: None,
}
}
pub fn tags(
column_id: impl Into<SharedString>,
tags: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
let tags: Arc<[SharedString]> = tags.into_iter().map(Into::into).collect::<Vec<_>>().into();
let value = tags
.iter()
.map(AsRef::<str>::as_ref)
.collect::<Vec<_>>()
.join(", ");
Self {
column_id: column_id.into(),
value: value.into(),
kind: RecordCellKind::Tags,
tags,
status_tone: None,
change_before: None,
}
}
pub fn change(
column_id: impl Into<SharedString>,
before: Option<SharedString>,
after: Option<SharedString>,
tone: RecordStatusTone,
) -> Self {
Self {
column_id: column_id.into(),
value: after.unwrap_or_default(),
kind: RecordCellKind::Change,
tags: Arc::from([]),
status_tone: Some(tone),
change_before: before,
}
}
pub fn change_before(&self) -> Option<&SharedString> {
self.change_before.as_ref()
}
pub fn accessible_value(&self) -> SharedString {
match (&self.kind, &self.change_before) {
(RecordCellKind::Change, Some(before)) if self.value.is_empty() => {
format!("removed {before}").into()
}
(RecordCellKind::Change, Some(before)) => {
format!("was {before}, now {}", self.value).into()
}
(RecordCellKind::Change, None) => format!("added {}", self.value).into(),
_ => self.value.clone(),
}
}
pub fn status(
column_id: impl Into<SharedString>,
value: impl Into<SharedString>,
tone: RecordStatusTone,
) -> Self {
Self {
column_id: column_id.into(),
value: value.into(),
kind: RecordCellKind::Status,
tags: Arc::from([]),
status_tone: Some(tone),
change_before: None,
}
}
pub fn column_id(&self) -> &str {
self.column_id.as_ref()
}
pub fn value(&self) -> &str {
self.value.as_ref()
}
pub fn kind(&self) -> RecordCellKind {
self.kind
}
pub fn tag_values(&self) -> &[SharedString] {
&self.tags
}
pub fn status_tone(&self) -> Option<RecordStatusTone> {
self.status_tone
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordRow {
id: SharedString,
label: SharedString,
cells: Vec<RecordCell>,
disabled: bool,
}
impl RecordRow {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
cells: Vec::new(),
disabled: false,
}
}
pub fn cells(mut self, cells: impl IntoIterator<Item = RecordCell>) -> Self {
self.cells = cells.into_iter().collect();
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn id(&self) -> &str {
self.id.as_ref()
}
pub fn label(&self) -> &str {
self.label.as_ref()
}
pub fn cell(&self, column_id: &str) -> Option<&RecordCell> {
self.cells
.iter()
.find(|cell| cell.column_id.as_ref() == column_id)
}
pub fn all_cells(&self) -> &[RecordCell] {
&self.cells
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
}
pub(crate) fn record_columns_have_unique_ids(columns: &[RecordColumn]) -> bool {
let mut seen = HashSet::with_capacity(columns.len());
columns.iter().all(|column| seen.insert(column.id()))
}
pub(crate) fn record_rows_have_unique_ids(rows: &[RecordRow]) -> bool {
let mut row_ids = HashSet::with_capacity(rows.len());
rows.iter().all(|row| {
let mut cell_ids = HashSet::with_capacity(row.cells.len());
row_ids.insert(row.id())
&& row
.cells
.iter()
.all(|cell| cell_ids.insert(cell.column_id()))
})
}
pub(crate) trait RecordCellProvider: Send + Sync {
fn cell(&self, row_ix: usize, column_id: &str) -> Option<RecordCell>;
}
pub(crate) fn escape_markdown_text(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
if matches!(
character,
'\\' | '`'
| '*'
| '_'
| '{'
| '}'
| '['
| ']'
| '<'
| '>'
| '('
| ')'
| '#'
| '+'
| '-'
| '.'
| '!'
| '|'
| '~'
) {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
fn records_table_footer(
id: &SharedString,
footer: SharedString,
cx: &App,
) -> impl gpui::IntoElement {
let tokens = cx.theme().semantic_tokens();
let debug_id = id.clone();
div()
.debug_selector(move || format!("records-table-footer-{debug_id}"))
.w_full()
.flex_none()
.flex()
.items_center()
.pt(tokens.spacing.xs)
.px(tokens.spacing.sm)
.child(crate::surface::hint(footer, cx))
}
fn records_table_frame(id: SharedString, label: SharedString) -> Stateful<Div> {
let debug_id = id.clone();
div()
.id(format!("records-table-{id}"))
.debug_selector(move || format!("records-table-{debug_id}"))
.size_full()
.role(Role::Table)
.aria_label(label)
}
fn scoped_records_id(kind: &str, component_id: &str, local_id: &str) -> String {
format!(
"records-{kind}-{}:{component_id}{local_id}",
component_id.len()
)
}
fn records_state_glyph(role: Role) -> AnyElement {
match role {
Role::ProgressIndicator => Spinner::new().xsmall().into_any_element(),
Role::Alert => Icon::new(IconName::CircleX).small().into_any_element(),
_ => Icon::new(IconName::Inbox).small().into_any_element(),
}
}
fn records_state_text(scoped_id: &str, role: Role, label: SharedString) -> AnyElement {
if role == Role::ProgressIndicator {
Shimmer::new(format!("{scoped_id}-shimmer"), label).into_any_element()
} else {
TextView::markdown(
format!("{scoped_id}-text"),
escape_markdown_text(label.as_ref()),
)
.selectable(true)
.into_any_element()
}
}
fn records_inline_state_frame(
component_id: &str,
id: &str,
role: Role,
label: SharedString,
cx: &App,
) -> Stateful<Div> {
let scoped_id = scoped_records_id("state", component_id, id);
let tokens = cx.theme().semantic_tokens();
div()
.id(scoped_id.clone())
.debug_selector({
let scoped_id = scoped_id.clone();
move || scoped_id.clone()
})
.w_full()
.flex_none()
.flex()
.items_center()
.justify_center()
.py(tokens.spacing.xxs)
.gap(tokens.spacing.xs)
.role(role)
.aria_label(label.clone())
.text_color(match role {
Role::Alert => cx.theme().danger,
_ => cx.theme().muted_foreground,
})
.child(records_state_glyph(role))
.child(records_state_text(&scoped_id, role, label))
}
pub struct RecordsTable {
style: gpui::StyleRefinement,
id: SharedString,
label: SharedString,
footer: Option<SharedString>,
fit: ColumnFit,
row_height: Option<Pixels>,
measured_width: Option<Pixels>,
divided_width: Option<Pixels>,
columns: Arc<[RecordColumn]>,
records: Progressive<Arc<[RecordRow]>>,
rows_by_id: StableIdIndex,
columns_by_id: StableIdIndex,
selected_row_id: Option<SharedString>,
sort_column_id: Option<SharedString>,
sort_direction: Option<RecordSortDirection>,
activation_label: SharedString,
pending_suppressed_selection_events: usize,
pending_pointer_row_id: Option<SharedString>,
viewport_row_anchor_id: Option<SharedString>,
viewport_column_anchor_id: Option<SharedString>,
resolved_layout: ResolvedLayoutKey,
table: gpui::Entity<TableState<RecordsDelegate>>,
_table_subscription: Subscription,
}
impl RecordsTable {
pub fn new(
id: impl Into<SharedString>,
label: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let id = id.into();
let owner = cx.weak_entity();
let delegate_id = id.clone();
let rem_size = window.rem_size();
let table = cx.new(|cx| {
TableState::new(
RecordsDelegate::empty(owner, delegate_id, rem_size),
window,
cx,
)
.loop_selection(false)
.col_selectable(false)
.col_movable(false)
.row_selectable(true)
.sortable(false)
});
let table_subscription = cx.subscribe(&table, |this, _, event, cx| {
this.handle_table_event(event, cx);
});
let mut resolved_layout = ResolvedLayoutKey::default();
resolved_layout.observe(rem_size);
Self {
style: gpui::StyleRefinement::default(),
id,
label: label.into(),
footer: None,
fit: ColumnFit::default(),
row_height: None,
measured_width: None,
divided_width: None,
columns: Arc::from([]),
records: Progressive::pending(Arc::from([])),
rows_by_id: StableIdIndex::default(),
columns_by_id: StableIdIndex::default(),
selected_row_id: None,
sort_column_id: None,
sort_direction: None,
activation_label: "Open".into(),
pending_suppressed_selection_events: 0,
pending_pointer_row_id: None,
viewport_row_anchor_id: None,
viewport_column_anchor_id: None,
resolved_layout,
table,
_table_subscription: table_subscription,
}
}
pub fn set_row_action_visibility(
&mut self,
visibility: RowActionVisibility,
cx: &mut Context<Self>,
) {
self.table.update(cx, |table, cx| {
table.delegate_mut().row_action_visibility = visibility;
cx.notify();
});
}
pub fn set_row_action_placement(
&mut self,
placement: RowActionPlacement,
cx: &mut Context<Self>,
) {
self.table.update(cx, |table, cx| {
table.delegate_mut().row_action_placement = placement;
cx.notify();
});
}
pub fn set_activation_label(
&mut self,
activation_label: impl Into<SharedString>,
cx: &mut Context<Self>,
) {
self.activation_label = activation_label.into();
let activation_label = self.activation_label.clone();
self.table.update(cx, |table, cx| {
table.delegate_mut().activation_label = activation_label;
cx.notify();
});
}
pub(crate) fn set_row_reorder_response(
&mut self,
response: Option<Duration>,
cx: &mut Context<Self>,
) {
self.table.update(cx, |table, cx| {
table.delegate_mut().row_reorder.set_response(response);
cx.notify();
});
}
pub(crate) fn visible_row_count(&self, cx: &App) -> usize {
self.table.read(cx).delegate().row_reorder.visible_len()
}
pub(crate) fn animating_row_count(&self, cx: &App) -> usize {
self.table.read(cx).delegate().row_reorder.animating_len()
}
pub fn set_footer(&mut self, footer: Option<SharedString>, cx: &mut Context<Self>) {
if self.footer != footer {
self.footer = footer;
cx.notify();
}
}
pub fn set_column_fit(&mut self, fit: ColumnFit, cx: &mut Context<Self>) {
if self.fit == fit {
return;
}
self.fit = fit;
self.divide_columns(cx);
}
pub fn column_fit(&self) -> ColumnFit {
self.fit
}
pub fn set_row_height(&mut self, row_height: Option<Pixels>, cx: &mut Context<Self>) {
if self.row_height == row_height {
return;
}
self.row_height = row_height;
cx.notify();
}
pub fn row_height(&self) -> Option<Pixels> {
self.row_height
}
fn push_column_widths(&mut self, cx: &mut Context<Self>) {
let columns = self.columns.clone();
let (fit, measured) = (self.fit, self.measured_width);
self.table.update(cx, |table, cx| {
let lane = crate::scrolling::control_lane(cx);
let available = measured.map(|measured| measured - lane);
let rem_size = table.delegate().rem_size;
table.delegate_mut().column_widths =
resolve_column_widths(&columns, fit, available, rem_size);
table.delegate_mut().fit = fit;
table.refresh(cx);
cx.notify();
});
}
fn observe_width(&mut self, width: Pixels, cx: &mut Context<Self>) {
if self.measured_width == Some(width) {
return;
}
self.measured_width = Some(width);
cx.notify();
}
fn columns_are_divided(&self) -> bool {
self.fit == ColumnFit::Intrinsic || self.divided_width == self.measured_width
}
fn divide_columns(&mut self, cx: &mut Context<Self>) {
self.divided_width = self.measured_width;
self.push_column_widths(cx);
}
pub fn set_columns(
&mut self,
columns: impl IntoIterator<Item = RecordColumn>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let columns = columns.into_iter().collect::<Vec<_>>();
let Some(columns_by_id) = index_valid_columns(&columns) else {
return;
};
let anchor_column_id = self.viewport_column_anchor_id.clone().or_else(|| {
let fixed_columns = self.columns.iter().filter(|column| column.fixed).count();
self.columns
.get(
self.table
.read(cx)
.visible_range()
.cols()
.start
.saturating_add(fixed_columns),
)
.map(|column| column.id.clone())
});
self.columns = columns.into();
self.columns_by_id = columns_by_id;
if self.sort_column_id.as_ref().is_some_and(|sort_column_id| {
!self
.columns_by_id
.position(sort_column_id)
.is_some_and(|col_ix| self.columns[col_ix].sortable)
}) {
self.sort_column_id = None;
self.sort_direction = None;
}
let columns = self.columns.clone();
let sort_column_id = self.sort_column_id.clone();
let sort_direction = self.sort_direction;
let anchor_column_ix = anchor_column_id
.as_ref()
.and_then(|anchor| self.columns_by_id.position(anchor));
self.table.update(cx, |table, cx| {
table.delegate_mut().columns = columns;
table.delegate_mut().sort_column_id = sort_column_id;
table.delegate_mut().sort_direction = sort_direction;
table.refresh(cx);
if let Some(column_ix) = anchor_column_ix {
table.scroll_to_col(column_ix, cx);
}
cx.notify();
});
self.divide_columns(cx);
}
pub fn set_records(
&mut self,
records: Progressive<Arc<[RecordRow]>>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let _ = self.set_records_snapshot(records, cx);
}
pub(crate) fn set_records_snapshot(
&mut self,
records: Progressive<Arc<[RecordRow]>>,
cx: &mut Context<Self>,
) -> bool {
self.set_records_snapshot_with_cell_provider(records, None, cx)
}
pub(crate) fn set_records_snapshot_with_cell_provider(
&mut self,
records: Progressive<Arc<[RecordRow]>>,
cell_provider: Option<Arc<dyn RecordCellProvider>>,
cx: &mut Context<Self>,
) -> bool {
let Some(accepted_rows_by_id) = index_valid_rows(records.content()) else {
return false;
};
let anchor_row_id = self.viewport_row_anchor_id.clone().or_else(|| {
self.records
.content()
.get(self.table.read(cx).visible_range().rows().start)
.map(|row| row.id.clone())
});
let old_visible_range = self.table.read(cx).visible_range().rows().clone();
let row_reorder_enabled = self.table.read(cx).delegate().row_reorder.is_enabled();
let visible_row_ids = if row_reorder_enabled {
let rendered = self
.table
.read(cx)
.delegate()
.row_reorder
.visible_ids()
.clone();
if rendered.is_empty() {
self.records
.content()
.get(old_visible_range.clone())
.unwrap_or_default()
.iter()
.map(|row| row.id.clone())
.collect::<Vec<_>>()
} else {
rendered.into_iter().collect()
}
} else {
Vec::new()
};
let anchor_row_ix = anchor_row_id
.as_ref()
.and_then(|anchor| accepted_rows_by_id.position(anchor))
.or_else(|| {
visible_row_ids
.iter()
.filter_map(|row_id| accepted_rows_by_id.position(row_id))
.min()
});
let reorder_motion = if row_reorder_enabled && crate::motion::motion_is_full(cx) {
let old_visible_start = old_visible_range.start;
let visible_len = old_visible_range.len().max(visible_row_ids.len()).max(1);
let new_visible_start = anchor_row_ix
.unwrap_or(old_visible_start.min(records.content().len().saturating_sub(1)));
let new_visible_end = new_visible_start
.saturating_add(visible_len)
.min(records.content().len());
let travelled = visible_row_ids.iter().filter_map(|row_id| {
let old_ix = self.rows_by_id.position(row_id)?;
let new_ix = accepted_rows_by_id.position(row_id)?;
if !(new_visible_start..new_visible_end).contains(&new_ix) {
return None;
}
let old_position = old_ix.saturating_sub(old_visible_start);
let new_position = new_ix.saturating_sub(new_visible_start);
let displacement =
Size::Medium.table_row_height() * (old_position as f32 - new_position as f32);
Some((row_id, displacement))
});
self.table
.read(cx)
.delegate()
.row_reorder
.project(travelled)
} else {
HashMap::new()
};
self.records = records;
self.rows_by_id = accepted_rows_by_id;
let desired_row_ix = self
.selected_row_id
.as_ref()
.and_then(|selected| self.rows_by_id.position(selected))
.filter(|row_ix| !self.records.content()[*row_ix].disabled);
if self.selected_row_id.is_some() && desired_row_ix.is_none() {
self.selected_row_id = None;
}
let records = self.records.clone();
let selected_row_id = self.selected_row_id.clone();
if desired_row_ix.is_some() {
self.pending_suppressed_selection_events =
self.pending_suppressed_selection_events.saturating_add(1);
}
self.table.update(cx, |table, cx| {
let delegate = table.delegate_mut();
delegate.records = records;
delegate.cell_provider = cell_provider;
delegate.selected_row_id = selected_row_id.clone();
delegate.row_reorder.accept(reorder_motion);
table.refresh(cx);
if let Some(row_ix) = desired_row_ix {
table.set_selected_row(row_ix, cx);
} else {
table.clear_selection(cx);
}
if let Some(row_ix) = anchor_row_ix {
table.scroll_to_row(row_ix, cx);
}
cx.notify();
});
cx.notify();
true
}
pub fn set_selected_row(
&mut self,
row_id: impl Into<SharedString>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let row_id = row_id.into();
let Some(row_ix) = self
.rows_by_id
.position(&row_id)
.filter(|row_ix| !self.records.content()[*row_ix].disabled)
else {
return;
};
self.selected_row_id = Some(row_id);
self.pending_suppressed_selection_events =
self.pending_suppressed_selection_events.saturating_add(1);
let selected_row_id = self.selected_row_id.clone();
self.table.update(cx, |table, cx| {
table.delegate_mut().selected_row_id = selected_row_id;
table.set_selected_row(row_ix, cx);
});
}
pub fn clear_selected_row(&mut self, cx: &mut Context<Self>) {
self.selected_row_id = None;
self.table.update(cx, |table, cx| {
table.delegate_mut().selected_row_id = None;
table.clear_selection(cx);
});
}
pub fn selected_row_id(&self) -> Option<&str> {
self.selected_row_id.as_deref()
}
pub fn set_sort(
&mut self,
column_id: impl Into<SharedString>,
direction: Option<RecordSortDirection>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let column_id = column_id.into();
if direction.is_some()
&& !self
.columns_by_id
.position(&column_id)
.is_some_and(|col_ix| self.columns[col_ix].sortable)
{
return;
}
self.sort_column_id = direction.map(|_| column_id.clone());
self.sort_direction = direction;
let sort_column_id = self.sort_column_id.clone();
self.table.update(cx, |table, cx| {
table.delegate_mut().sort_column_id = sort_column_id;
table.delegate_mut().sort_direction = direction;
cx.notify();
});
}
pub fn sort(&self) -> Option<(&str, RecordSortDirection)> {
self.sort_column_id.as_deref().zip(self.sort_direction)
}
pub fn scroll_to_row(&mut self, row_id: &str, cx: &mut Context<Self>) {
let Some(row_ix) = self.rows_by_id.position(row_id) else {
return;
};
self.table
.update(cx, |table, cx| table.scroll_to_row(row_ix, cx));
self.viewport_row_anchor_id = Some(row_id.into());
}
pub fn scroll_to_column(&mut self, column_id: &str, cx: &mut Context<Self>) {
let Some(col_ix) = self.columns_by_id.position(column_id) else {
return;
};
self.table
.update(cx, |table, cx| table.scroll_to_col(col_ix, cx));
self.viewport_column_anchor_id = Some(column_id.into());
}
pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
self.table.focus_handle(cx).focus(window, cx);
}
fn resolve_layout(&mut self, rem_size: Pixels, cx: &mut Context<Self>) {
if !self.resolved_layout.observe(rem_size) {
return;
}
let widths_follow_the_rem = self
.columns
.iter()
.any(|column| matches!(column.width, RecordColumnWidth::Rems(_)));
self.table.update(cx, |table, _| {
table.delegate_mut().rem_size = rem_size;
});
if widths_follow_the_rem || self.fit == ColumnFit::Fill {
self.divide_columns(cx);
}
}
fn handle_table_event(&mut self, event: &TableEvent, cx: &mut Context<Self>) {
match event {
TableEvent::SelectRow(_) if self.pending_suppressed_selection_events > 0 => {
self.pending_suppressed_selection_events =
self.pending_suppressed_selection_events.saturating_sub(1);
}
TableEvent::SelectRow(row_ix) => {
if let Some(row) = self.records.content().get(*row_ix) {
let from_pointer = self.pending_pointer_row_id.take().as_ref() == Some(&row.id);
if !row.disabled {
cx.emit(RecordsTableEvent::SelectionRequested {
id: self.id.clone(),
row_id: row.id.clone(),
});
} else if !from_pointer {
self.request_enabled_beyond(*row_ix, cx);
}
}
self.defer_controlled_selection_sync(cx);
}
TableEvent::ClearSelection => {
self.defer_controlled_selection_sync(cx);
}
TableEvent::DoubleClickedRow(row_ix) => {
if let Some(row) = self.records.content().get(*row_ix)
&& !row.disabled
{
cx.emit(RecordsTableEvent::ActivationRequested {
id: self.id.clone(),
row_id: row.id.clone(),
});
}
}
_ => {}
}
}
fn defer_controlled_selection_sync(&self, cx: &mut Context<Self>) {
let owner = cx.weak_entity();
cx.defer(move |cx| {
let _ = owner.update(cx, |table, cx| table.sync_controlled_selection(cx));
});
}
fn sync_controlled_selection(&mut self, cx: &mut Context<Self>) {
let desired_row_ix = self
.selected_row_id
.as_ref()
.and_then(|selected| self.rows_by_id.position(selected));
let current_row_ix = self.table.read(cx).selected_row();
if desired_row_ix == current_row_ix {
return;
}
if desired_row_ix.is_some() {
self.pending_suppressed_selection_events =
self.pending_suppressed_selection_events.saturating_add(1);
}
self.table.update(cx, |table, cx| {
if let Some(row_ix) = desired_row_ix {
table.set_selected_row(row_ix, cx);
} else if table.selected_row().is_some() {
table.clear_selection(cx);
}
});
}
fn request_sort(&mut self, column_id: SharedString, cx: &mut Context<Self>) {
let direction = if self.sort_column_id.as_ref() == Some(&column_id) {
match self.sort_direction {
None => Some(RecordSortDirection::Descending),
Some(RecordSortDirection::Descending) => Some(RecordSortDirection::Ascending),
Some(RecordSortDirection::Ascending) => None,
}
} else {
Some(RecordSortDirection::Descending)
};
cx.emit(RecordsTableEvent::SortRequested {
id: self.id.clone(),
column_id,
direction,
});
}
fn request_activation(&mut self, row_id: SharedString, cx: &mut Context<Self>) {
self.pending_pointer_row_id = None;
if self
.rows_by_id
.position(&row_id)
.is_some_and(|row_ix| !self.records.content()[row_ix].disabled)
{
cx.emit(RecordsTableEvent::ActivationRequested {
id: self.id.clone(),
row_id,
});
}
}
fn activate_selected(&mut self, _: &ActivateRecord, _: &mut Window, cx: &mut Context<Self>) {
let Some(row_id) = self.selected_row_id.clone() else {
cx.propagate();
return;
};
if self
.rows_by_id
.position(&row_id)
.is_none_or(|row_ix| self.records.content()[row_ix].disabled)
{
cx.propagate();
return;
}
cx.emit(RecordsTableEvent::ActivationRequested {
id: self.id.clone(),
row_id,
});
cx.stop_propagation();
}
fn request_enabled_beyond(&self, disabled_index: usize, cx: &mut Context<Self>) {
let current = self
.selected_row_id
.as_ref()
.and_then(|selected| self.rows_by_id.position(selected));
let forward = current.is_none_or(|current| disabled_index > current);
let next = if forward {
self.records
.content()
.iter()
.enumerate()
.skip(disabled_index.saturating_add(1))
.find(|(_, row)| !row.disabled)
} else {
self.records
.content()
.iter()
.enumerate()
.take(disabled_index)
.rev()
.find(|(_, row)| !row.disabled)
};
let Some((_, row)) = next else {
return;
};
cx.emit(RecordsTableEvent::SelectionRequested {
id: self.id.clone(),
row_id: row.id.clone(),
});
}
}
impl EventEmitter<RecordsTableEvent> for RecordsTable {}
impl Focusable for RecordsTable {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.table.focus_handle(cx)
}
}
impl gpui::Styled for RecordsTable {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl Render for RecordsTable {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
let rem_size = window.rem_size();
if !self.resolved_layout.matches(rem_size) {
cx.defer_in(window, move |table, _, cx| {
table.resolve_layout(rem_size, cx);
});
}
if !self.columns_are_divided() {
cx.defer_in(window, move |table, _, cx| {
table.divide_columns(cx);
});
}
let inline_status = (!self.records.content().is_empty())
.then(|| match self.records.state() {
ProgressState::Pending | ProgressState::Running => {
Some(records_inline_state_frame(
&self.id,
"records-loading",
Role::ProgressIndicator,
"Loading records".into(),
cx,
))
}
ProgressState::Failed(reason) => Some(records_inline_state_frame(
&self.id,
"records-error",
Role::Alert,
format!("Records unavailable: {reason}").into(),
cx,
)),
ProgressState::Complete => None,
})
.flatten();
records_table_frame(self.id.clone(), self.label.clone())
.flex()
.flex_col()
.min_h_0()
.key_context(RECORDS_TABLE_CONTEXT)
.text_color(cx.theme().foreground)
.border_1()
.border_color(cx.theme().transparent)
.track_focus(&self.table.focus_handle(cx))
.focus_visible(|style| style.border_color(cx.theme().ring))
.on_action(cx.listener(Self::activate_selected))
.when_some(inline_status, |this, status| this.child(status))
.child(
div()
.flex_1()
.min_h_0()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
.child(measured_grid_width(cx.weak_entity()))
.child(
DataTable::new(&self.table)
.stripe(false)
.bordered(false)
.when_some(self.row_height, |grid, height| {
grid.with_size(Size::Size(height))
}),
),
)
.when_some(self.footer.clone(), |frame, footer| {
frame.child(
gpui::deferred(records_table_footer(&self.id, footer, cx)).with_priority(1),
)
})
.refine_style(&self.style)
}
}
#[cfg(test)]
mod tests;