1use gpui_base::StyledExt as _;
4use std::{
5 collections::{HashMap, HashSet},
6 sync::Arc,
7 time::Duration,
8};
9
10use gpui::{
11 AnyElement, App, AppContext as _, Context, Div, EventEmitter, FocusHandle, Focusable,
12 InteractiveElement as _, IntoElement as _, KeyBinding, ParentElement as _, Pixels, Rems,
13 Render, Role, SharedString, Stateful, StatefulInteractiveElement as _, Styled as _,
14 Subscription, Window, div, prelude::FluentBuilder as _, rems,
15};
16use gpui_component::{
17 ActiveTheme as _, Icon, IconName, Sizable as _, Size,
18 spinner::Spinner,
19 table::{DataTable, TableEvent, TableState},
20 text::TextView,
21};
22
23use crate::{
24 motion::Shimmer,
25 resolved_layout::ResolvedLayoutKey,
26 stream::{ProgressState, Progressive},
27};
28
29mod delegate;
30mod reorder;
31mod stable_id;
32
33use delegate::RecordsDelegate;
34use stable_id::{StableIdIndex, index_valid_columns, index_valid_rows};
35
36const RECORDS_TABLE_CONTEXT: &str = "GpuiAiRecordsTable";
37gpui::actions!(
38 gpui_ai_records_table,
39 [
40 ActivateRecord
42 ]
43);
44
45pub(crate) fn init(cx: &mut App) {
46 cx.bind_keys([
47 KeyBinding::new("enter", ActivateRecord, Some(RECORDS_TABLE_CONTEXT)),
48 KeyBinding::new("space", ActivateRecord, Some(RECORDS_TABLE_CONTEXT)),
49 ]);
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum RecordSortDirection {
55 Ascending,
57 Descending,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum RowActionVisibility {
69 Always,
71 #[default]
73 OnHover,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum RowActionPlacement {
83 Inline,
85 #[default]
87 End,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub enum RecordColumnAlignment {
93 #[default]
95 Left,
96 Center,
98 Right,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub enum RecordCellKind {
105 Change,
108 #[default]
110 Text,
111 Tags,
113 Status,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum RecordStatusTone {
122 Positive,
124 Neutral,
126 Caution,
128 Critical,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum RecordsTableEvent {
135 SelectionRequested {
137 id: SharedString,
139 row_id: SharedString,
141 },
142 ActivationRequested {
144 id: SharedString,
146 row_id: SharedString,
148 },
149 SortRequested {
151 id: SharedString,
153 column_id: SharedString,
155 direction: Option<RecordSortDirection>,
157 },
158}
159
160#[derive(Debug, Clone, Copy, Default, PartialEq)]
168enum RecordColumnWidth {
169 #[default]
171 Unset,
172 Pixels(Pixels),
174 Rems(Rems),
176}
177
178impl RecordColumnWidth {
179 fn resolve(self, rem_size: Pixels) -> Option<Pixels> {
181 match self {
182 Self::Unset => None,
183 Self::Pixels(width) => Some(width),
184 Self::Rems(width) => Some(width.to_pixels(rem_size)),
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
197pub enum ColumnFit {
198 #[default]
206 Fill,
207 Intrinsic,
213}
214
215fn measured_grid_width(owner: gpui::WeakEntity<RecordsTable>) -> impl gpui::IntoElement {
222 gpui::canvas(
223 move |bounds, _, cx| {
224 let _ = owner.update(cx, |table, cx| table.observe_width(bounds.size.width, cx));
225 },
226 |_, _, _, _| {},
227 )
228 .absolute()
229 .inset_0()
230}
231
232fn resolve_column_widths(
248 columns: &[RecordColumn],
249 fit: ColumnFit,
250 available: Option<Pixels>,
251 rem_size: Pixels,
252) -> Arc<[Pixels]> {
253 let bases = columns
254 .iter()
255 .map(|column| {
256 column
257 .width
258 .resolve(rem_size)
259 .unwrap_or_else(default_column_width)
260 })
261 .collect::<Vec<_>>();
262 let (ColumnFit::Fill, Some(available)) = (fit, available) else {
263 return bases.into();
264 };
265 let asked_for = bases.iter().fold(Pixels::ZERO, |total, base| total + *base);
266 let surplus = available - asked_for;
267 if surplus <= Pixels::ZERO {
268 return bases.into();
269 }
270 let asked = columns.iter().any(|column| column.fill.is_some());
273 let shares = columns
274 .iter()
275 .zip(&bases)
276 .map(|(column, base)| match (asked, column.fill) {
277 (true, Some(share)) => share,
278 (true, None) => 0.,
279 (false, _) => f32::from(*base),
280 })
281 .collect::<Vec<_>>();
282 let total: f32 = shares.iter().sum();
283 if total <= 0. {
284 return bases.into();
285 }
286 bases
287 .iter()
288 .zip(&shares)
289 .map(|(base, share)| *base + surplus * (share / total))
290 .collect()
291}
292
293fn default_column_width() -> Pixels {
298 gpui_component::table::Column::default().width
299}
300
301#[derive(Debug, Clone, PartialEq)]
306pub struct RecordColumn {
307 id: SharedString,
308 label: SharedString,
309 sortable: bool,
310 width: RecordColumnWidth,
311 fill: Option<f32>,
314 alignment: RecordColumnAlignment,
315 fixed: bool,
316 description: Option<SharedString>,
317}
318
319impl RecordColumn {
320 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
322 Self {
323 id: id.into(),
324 label: label.into(),
325 sortable: false,
326 width: RecordColumnWidth::Unset,
327 fill: None,
328 alignment: RecordColumnAlignment::Left,
329 fixed: false,
330 description: None,
331 }
332 }
333
334 pub fn sortable(mut self, sortable: bool) -> Self {
336 self.sortable = sortable;
337 self
338 }
339
340 pub fn width(mut self, width: Pixels) -> Self {
342 self.width = RecordColumnWidth::Pixels(width);
343 self
344 }
345
346 pub fn width_in_rems(mut self, width: f32) -> Self {
353 self.width = RecordColumnWidth::Rems(rems(width));
354 self
355 }
356
357 pub fn fill(mut self, share: f32) -> Self {
376 self.fill = Some(share.max(0.));
377 self
378 }
379
380 pub fn alignment(mut self, alignment: RecordColumnAlignment) -> Self {
382 self.alignment = alignment;
383 self
384 }
385
386 pub fn fixed(mut self, fixed: bool) -> Self {
388 self.fixed = fixed;
389 self
390 }
391
392 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
394 self.description = Some(description.into());
395 self
396 }
397
398 pub fn id(&self) -> &str {
400 self.id.as_ref()
401 }
402
403 pub fn label(&self) -> &str {
405 self.label.as_ref()
406 }
407
408 pub fn is_sortable(&self) -> bool {
410 self.sortable
411 }
412
413 pub fn configured_width(&self) -> Option<Pixels> {
418 match self.width {
419 RecordColumnWidth::Pixels(width) => Some(width),
420 RecordColumnWidth::Unset | RecordColumnWidth::Rems(_) => None,
421 }
422 }
423
424 pub fn column_alignment(&self) -> RecordColumnAlignment {
426 self.alignment
427 }
428
429 pub fn is_fixed(&self) -> bool {
431 self.fixed
432 }
433
434 pub fn accessible_description(&self) -> Option<&str> {
436 self.description.as_deref()
437 }
438}
439
440#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct RecordCell {
443 column_id: SharedString,
444 value: SharedString,
445 kind: RecordCellKind,
446 tags: Arc<[SharedString]>,
447 status_tone: Option<RecordStatusTone>,
448 change_before: Option<SharedString>,
449}
450
451impl RecordCell {
452 pub fn new(column_id: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
454 Self {
455 column_id: column_id.into(),
456 value: value.into(),
457 kind: RecordCellKind::Text,
458 tags: Arc::from([]),
459 status_tone: None,
460 change_before: None,
461 }
462 }
463
464 pub fn tags(
466 column_id: impl Into<SharedString>,
467 tags: impl IntoIterator<Item = impl Into<SharedString>>,
468 ) -> Self {
469 let tags: Arc<[SharedString]> = tags.into_iter().map(Into::into).collect::<Vec<_>>().into();
470 let value = tags
471 .iter()
472 .map(AsRef::<str>::as_ref)
473 .collect::<Vec<_>>()
474 .join(", ");
475 Self {
476 column_id: column_id.into(),
477 value: value.into(),
478 kind: RecordCellKind::Tags,
479 tags,
480 status_tone: None,
481 change_before: None,
482 }
483 }
484
485 pub fn change(
493 column_id: impl Into<SharedString>,
494 before: Option<SharedString>,
495 after: Option<SharedString>,
496 tone: RecordStatusTone,
497 ) -> Self {
498 Self {
499 column_id: column_id.into(),
500 value: after.unwrap_or_default(),
501 kind: RecordCellKind::Change,
502 tags: Arc::from([]),
503 status_tone: Some(tone),
504 change_before: before,
505 }
506 }
507
508 pub fn change_before(&self) -> Option<&SharedString> {
510 self.change_before.as_ref()
511 }
512
513 pub fn accessible_value(&self) -> SharedString {
518 match (&self.kind, &self.change_before) {
519 (RecordCellKind::Change, Some(before)) if self.value.is_empty() => {
520 format!("removed {before}").into()
521 }
522 (RecordCellKind::Change, Some(before)) => {
523 format!("was {before}, now {}", self.value).into()
524 }
525 (RecordCellKind::Change, None) => format!("added {}", self.value).into(),
526 _ => self.value.clone(),
527 }
528 }
529
530 pub fn status(
532 column_id: impl Into<SharedString>,
533 value: impl Into<SharedString>,
534 tone: RecordStatusTone,
535 ) -> Self {
536 Self {
537 column_id: column_id.into(),
538 value: value.into(),
539 kind: RecordCellKind::Status,
540 tags: Arc::from([]),
541 status_tone: Some(tone),
542 change_before: None,
543 }
544 }
545
546 pub fn column_id(&self) -> &str {
548 self.column_id.as_ref()
549 }
550
551 pub fn value(&self) -> &str {
553 self.value.as_ref()
554 }
555
556 pub fn kind(&self) -> RecordCellKind {
558 self.kind
559 }
560
561 pub fn tag_values(&self) -> &[SharedString] {
563 &self.tags
564 }
565
566 pub fn status_tone(&self) -> Option<RecordStatusTone> {
568 self.status_tone
569 }
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct RecordRow {
575 id: SharedString,
576 label: SharedString,
577 cells: Vec<RecordCell>,
578 disabled: bool,
579}
580
581impl RecordRow {
582 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
584 Self {
585 id: id.into(),
586 label: label.into(),
587 cells: Vec::new(),
588 disabled: false,
589 }
590 }
591
592 pub fn cells(mut self, cells: impl IntoIterator<Item = RecordCell>) -> Self {
594 self.cells = cells.into_iter().collect();
595 self
596 }
597
598 pub fn disabled(mut self, disabled: bool) -> Self {
600 self.disabled = disabled;
601 self
602 }
603
604 pub fn id(&self) -> &str {
606 self.id.as_ref()
607 }
608
609 pub fn label(&self) -> &str {
611 self.label.as_ref()
612 }
613
614 pub fn cell(&self, column_id: &str) -> Option<&RecordCell> {
616 self.cells
617 .iter()
618 .find(|cell| cell.column_id.as_ref() == column_id)
619 }
620
621 pub fn all_cells(&self) -> &[RecordCell] {
623 &self.cells
624 }
625
626 pub fn is_disabled(&self) -> bool {
628 self.disabled
629 }
630}
631
632pub(crate) fn record_columns_have_unique_ids(columns: &[RecordColumn]) -> bool {
633 let mut seen = HashSet::with_capacity(columns.len());
634 columns.iter().all(|column| seen.insert(column.id()))
635}
636
637pub(crate) fn record_rows_have_unique_ids(rows: &[RecordRow]) -> bool {
638 let mut row_ids = HashSet::with_capacity(rows.len());
639 rows.iter().all(|row| {
640 let mut cell_ids = HashSet::with_capacity(row.cells.len());
641 row_ids.insert(row.id())
642 && row
643 .cells
644 .iter()
645 .all(|cell| cell_ids.insert(cell.column_id()))
646 })
647}
648
649pub(crate) trait RecordCellProvider: Send + Sync {
650 fn cell(&self, row_ix: usize, column_id: &str) -> Option<RecordCell>;
651}
652
653pub(crate) fn escape_markdown_text(value: &str) -> String {
654 let mut escaped = String::with_capacity(value.len());
655 for character in value.chars() {
656 if matches!(
657 character,
658 '\\' | '`'
659 | '*'
660 | '_'
661 | '{'
662 | '}'
663 | '['
664 | ']'
665 | '<'
666 | '>'
667 | '('
668 | ')'
669 | '#'
670 | '+'
671 | '-'
672 | '.'
673 | '!'
674 | '|'
675 | '~'
676 ) {
677 escaped.push('\\');
678 }
679 escaped.push(character);
680 }
681 escaped
682}
683
684fn records_table_footer(
694 id: &SharedString,
695 footer: SharedString,
696 cx: &App,
697) -> impl gpui::IntoElement {
698 let tokens = cx.theme().semantic_tokens();
699 let debug_id = id.clone();
700 div()
701 .debug_selector(move || format!("records-table-footer-{debug_id}"))
702 .w_full()
703 .flex_none()
704 .flex()
705 .items_center()
706 .pt(tokens.spacing.xs)
707 .px(tokens.spacing.sm)
708 .child(crate::surface::hint(footer, cx))
709}
710
711fn records_table_frame(id: SharedString, label: SharedString) -> Stateful<Div> {
712 let debug_id = id.clone();
713 div()
714 .id(format!("records-table-{id}"))
715 .debug_selector(move || format!("records-table-{debug_id}"))
716 .size_full()
717 .role(Role::Table)
718 .aria_label(label)
719}
720
721fn scoped_records_id(kind: &str, component_id: &str, local_id: &str) -> String {
722 format!(
723 "records-{kind}-{}:{component_id}{local_id}",
724 component_id.len()
725 )
726}
727
728fn records_state_glyph(role: Role) -> AnyElement {
731 match role {
732 Role::ProgressIndicator => Spinner::new().xsmall().into_any_element(),
733 Role::Alert => Icon::new(IconName::CircleX).small().into_any_element(),
734 _ => Icon::new(IconName::Inbox).small().into_any_element(),
735 }
736}
737
738fn records_state_text(scoped_id: &str, role: Role, label: SharedString) -> AnyElement {
740 if role == Role::ProgressIndicator {
741 Shimmer::new(format!("{scoped_id}-shimmer"), label).into_any_element()
742 } else {
743 TextView::markdown(
744 format!("{scoped_id}-text"),
745 escape_markdown_text(label.as_ref()),
746 )
747 .selectable(true)
748 .into_any_element()
749 }
750}
751
752fn records_inline_state_frame(
753 component_id: &str,
754 id: &str,
755 role: Role,
756 label: SharedString,
757 cx: &App,
758) -> Stateful<Div> {
759 let scoped_id = scoped_records_id("state", component_id, id);
760 let tokens = cx.theme().semantic_tokens();
761 div()
762 .id(scoped_id.clone())
763 .debug_selector({
764 let scoped_id = scoped_id.clone();
765 move || scoped_id.clone()
766 })
767 .w_full()
768 .flex_none()
769 .flex()
770 .items_center()
771 .justify_center()
772 .py(tokens.spacing.xxs)
773 .gap(tokens.spacing.xs)
774 .role(role)
775 .aria_label(label.clone())
776 .text_color(match role {
777 Role::Alert => cx.theme().danger,
778 _ => cx.theme().muted_foreground,
779 })
780 .child(records_state_glyph(role))
781 .child(records_state_text(&scoped_id, role, label))
782}
783
784pub struct RecordsTable {
789 style: gpui::StyleRefinement,
796 id: SharedString,
797 label: SharedString,
798 footer: Option<SharedString>,
806 fit: ColumnFit,
808 row_height: Option<Pixels>,
810 measured_width: Option<Pixels>,
817 divided_width: Option<Pixels>,
820 columns: Arc<[RecordColumn]>,
821 records: Progressive<Arc<[RecordRow]>>,
822 rows_by_id: StableIdIndex,
824 columns_by_id: StableIdIndex,
826 selected_row_id: Option<SharedString>,
827 sort_column_id: Option<SharedString>,
828 sort_direction: Option<RecordSortDirection>,
829 activation_label: SharedString,
830 pending_suppressed_selection_events: usize,
831 pending_pointer_row_id: Option<SharedString>,
832 viewport_row_anchor_id: Option<SharedString>,
833 viewport_column_anchor_id: Option<SharedString>,
834 resolved_layout: ResolvedLayoutKey,
835 table: gpui::Entity<TableState<RecordsDelegate>>,
836 _table_subscription: Subscription,
837}
838
839impl RecordsTable {
840 pub fn new(
842 id: impl Into<SharedString>,
843 label: impl Into<SharedString>,
844 window: &mut Window,
845 cx: &mut Context<Self>,
846 ) -> Self {
847 let id = id.into();
848 let owner = cx.weak_entity();
849 let delegate_id = id.clone();
850 let rem_size = window.rem_size();
854 let table = cx.new(|cx| {
855 TableState::new(
856 RecordsDelegate::empty(owner, delegate_id, rem_size),
857 window,
858 cx,
859 )
860 .loop_selection(false)
861 .col_selectable(false)
862 .col_movable(false)
863 .row_selectable(true)
864 .sortable(false)
865 });
866 let table_subscription = cx.subscribe(&table, |this, _, event, cx| {
867 this.handle_table_event(event, cx);
868 });
869 let mut resolved_layout = ResolvedLayoutKey::default();
870 resolved_layout.observe(rem_size);
871
872 Self {
873 style: gpui::StyleRefinement::default(),
874 id,
875 label: label.into(),
876 footer: None,
877 fit: ColumnFit::default(),
878 row_height: None,
879 measured_width: None,
880 divided_width: None,
881 columns: Arc::from([]),
882 records: Progressive::pending(Arc::from([])),
883 rows_by_id: StableIdIndex::default(),
884 columns_by_id: StableIdIndex::default(),
885 selected_row_id: None,
886 sort_column_id: None,
887 sort_direction: None,
888 activation_label: "Open".into(),
889 pending_suppressed_selection_events: 0,
890 pending_pointer_row_id: None,
891 viewport_row_anchor_id: None,
892 viewport_column_anchor_id: None,
893 resolved_layout,
894 table,
895 _table_subscription: table_subscription,
896 }
897 }
898
899 pub fn set_row_action_visibility(
901 &mut self,
902 visibility: RowActionVisibility,
903 cx: &mut Context<Self>,
904 ) {
905 self.table.update(cx, |table, cx| {
906 table.delegate_mut().row_action_visibility = visibility;
907 cx.notify();
908 });
909 }
910
911 pub fn set_row_action_placement(
913 &mut self,
914 placement: RowActionPlacement,
915 cx: &mut Context<Self>,
916 ) {
917 self.table.update(cx, |table, cx| {
918 table.delegate_mut().row_action_placement = placement;
919 cx.notify();
920 });
921 }
922
923 pub fn set_activation_label(
930 &mut self,
931 activation_label: impl Into<SharedString>,
932 cx: &mut Context<Self>,
933 ) {
934 self.activation_label = activation_label.into();
935 let activation_label = self.activation_label.clone();
936 self.table.update(cx, |table, cx| {
937 table.delegate_mut().activation_label = activation_label;
938 cx.notify();
939 });
940 }
941
942 pub(crate) fn set_row_reorder_response(
943 &mut self,
944 response: Option<Duration>,
945 cx: &mut Context<Self>,
946 ) {
947 self.table.update(cx, |table, cx| {
948 table.delegate_mut().row_reorder.set_response(response);
949 cx.notify();
950 });
951 }
952
953 pub(crate) fn visible_row_count(&self, cx: &App) -> usize {
954 self.table.read(cx).delegate().row_reorder.visible_len()
955 }
956
957 pub(crate) fn animating_row_count(&self, cx: &App) -> usize {
958 self.table.read(cx).delegate().row_reorder.animating_len()
959 }
960
961 pub fn set_footer(&mut self, footer: Option<SharedString>, cx: &mut Context<Self>) {
974 if self.footer != footer {
975 self.footer = footer;
976 cx.notify();
977 }
978 }
979
980 pub fn set_column_fit(&mut self, fit: ColumnFit, cx: &mut Context<Self>) {
994 if self.fit == fit {
995 return;
996 }
997 self.fit = fit;
998 self.divide_columns(cx);
999 }
1000
1001 pub fn column_fit(&self) -> ColumnFit {
1003 self.fit
1004 }
1005
1006 pub fn set_row_height(&mut self, row_height: Option<Pixels>, cx: &mut Context<Self>) {
1020 if self.row_height == row_height {
1021 return;
1022 }
1023 self.row_height = row_height;
1024 cx.notify();
1025 }
1026
1027 pub fn row_height(&self) -> Option<Pixels> {
1029 self.row_height
1030 }
1031
1032 fn push_column_widths(&mut self, cx: &mut Context<Self>) {
1037 let columns = self.columns.clone();
1038 let (fit, measured) = (self.fit, self.measured_width);
1039 self.table.update(cx, |table, cx| {
1040 let lane = crate::scrolling::control_lane(cx);
1047 let available = measured.map(|measured| measured - lane);
1048 let rem_size = table.delegate().rem_size;
1049 table.delegate_mut().column_widths =
1050 resolve_column_widths(&columns, fit, available, rem_size);
1051 table.delegate_mut().fit = fit;
1052 table.refresh(cx);
1053 cx.notify();
1054 });
1055 }
1056
1057 fn observe_width(&mut self, width: Pixels, cx: &mut Context<Self>) {
1067 if self.measured_width == Some(width) {
1068 return;
1069 }
1070 self.measured_width = Some(width);
1071 cx.notify();
1072 }
1073
1074 fn columns_are_divided(&self) -> bool {
1076 self.fit == ColumnFit::Intrinsic || self.divided_width == self.measured_width
1077 }
1078
1079 fn divide_columns(&mut self, cx: &mut Context<Self>) {
1081 self.divided_width = self.measured_width;
1082 self.push_column_widths(cx);
1083 }
1084
1085 pub fn set_columns(
1090 &mut self,
1091 columns: impl IntoIterator<Item = RecordColumn>,
1092 _window: &mut Window,
1093 cx: &mut Context<Self>,
1094 ) {
1095 let columns = columns.into_iter().collect::<Vec<_>>();
1096 let Some(columns_by_id) = index_valid_columns(&columns) else {
1097 return;
1098 };
1099 let anchor_column_id = self.viewport_column_anchor_id.clone().or_else(|| {
1100 let fixed_columns = self.columns.iter().filter(|column| column.fixed).count();
1101 self.columns
1102 .get(
1103 self.table
1104 .read(cx)
1105 .visible_range()
1106 .cols()
1107 .start
1108 .saturating_add(fixed_columns),
1109 )
1110 .map(|column| column.id.clone())
1111 });
1112 self.columns = columns.into();
1113 self.columns_by_id = columns_by_id;
1114 if self.sort_column_id.as_ref().is_some_and(|sort_column_id| {
1115 !self
1116 .columns_by_id
1117 .position(sort_column_id)
1118 .is_some_and(|col_ix| self.columns[col_ix].sortable)
1119 }) {
1120 self.sort_column_id = None;
1121 self.sort_direction = None;
1122 }
1123 let columns = self.columns.clone();
1124 let sort_column_id = self.sort_column_id.clone();
1125 let sort_direction = self.sort_direction;
1126 let anchor_column_ix = anchor_column_id
1127 .as_ref()
1128 .and_then(|anchor| self.columns_by_id.position(anchor));
1129 self.table.update(cx, |table, cx| {
1130 table.delegate_mut().columns = columns;
1131 table.delegate_mut().sort_column_id = sort_column_id;
1132 table.delegate_mut().sort_direction = sort_direction;
1133 table.refresh(cx);
1134 if let Some(column_ix) = anchor_column_ix {
1135 table.scroll_to_col(column_ix, cx);
1136 }
1137 cx.notify();
1138 });
1139 self.divide_columns(cx);
1142 }
1143
1144 pub fn set_records(
1149 &mut self,
1150 records: Progressive<Arc<[RecordRow]>>,
1151 _window: &mut Window,
1152 cx: &mut Context<Self>,
1153 ) {
1154 let _ = self.set_records_snapshot(records, cx);
1155 }
1156
1157 pub(crate) fn set_records_snapshot(
1158 &mut self,
1159 records: Progressive<Arc<[RecordRow]>>,
1160 cx: &mut Context<Self>,
1161 ) -> bool {
1162 self.set_records_snapshot_with_cell_provider(records, None, cx)
1163 }
1164
1165 pub(crate) fn set_records_snapshot_with_cell_provider(
1166 &mut self,
1167 records: Progressive<Arc<[RecordRow]>>,
1168 cell_provider: Option<Arc<dyn RecordCellProvider>>,
1169 cx: &mut Context<Self>,
1170 ) -> bool {
1171 let Some(accepted_rows_by_id) = index_valid_rows(records.content()) else {
1177 return false;
1178 };
1179 let anchor_row_id = self.viewport_row_anchor_id.clone().or_else(|| {
1180 self.records
1181 .content()
1182 .get(self.table.read(cx).visible_range().rows().start)
1183 .map(|row| row.id.clone())
1184 });
1185 let old_visible_range = self.table.read(cx).visible_range().rows().clone();
1186 let row_reorder_enabled = self.table.read(cx).delegate().row_reorder.is_enabled();
1187 let visible_row_ids = if row_reorder_enabled {
1188 let rendered = self
1189 .table
1190 .read(cx)
1191 .delegate()
1192 .row_reorder
1193 .visible_ids()
1194 .clone();
1195 if rendered.is_empty() {
1196 self.records
1197 .content()
1198 .get(old_visible_range.clone())
1199 .unwrap_or_default()
1200 .iter()
1201 .map(|row| row.id.clone())
1202 .collect::<Vec<_>>()
1203 } else {
1204 rendered.into_iter().collect()
1205 }
1206 } else {
1207 Vec::new()
1208 };
1209 let anchor_row_ix = anchor_row_id
1214 .as_ref()
1215 .and_then(|anchor| accepted_rows_by_id.position(anchor))
1216 .or_else(|| {
1217 visible_row_ids
1218 .iter()
1219 .filter_map(|row_id| accepted_rows_by_id.position(row_id))
1220 .min()
1221 });
1222 let reorder_motion = if row_reorder_enabled && crate::motion::motion_is_full(cx) {
1223 let old_visible_start = old_visible_range.start;
1224 let visible_len = old_visible_range.len().max(visible_row_ids.len()).max(1);
1225 let new_visible_start = anchor_row_ix
1226 .unwrap_or(old_visible_start.min(records.content().len().saturating_sub(1)));
1227 let new_visible_end = new_visible_start
1228 .saturating_add(visible_len)
1229 .min(records.content().len());
1230 let travelled = visible_row_ids.iter().filter_map(|row_id| {
1234 let old_ix = self.rows_by_id.position(row_id)?;
1235 let new_ix = accepted_rows_by_id.position(row_id)?;
1236 if !(new_visible_start..new_visible_end).contains(&new_ix) {
1237 return None;
1238 }
1239 let old_position = old_ix.saturating_sub(old_visible_start);
1240 let new_position = new_ix.saturating_sub(new_visible_start);
1241 let displacement =
1242 Size::Medium.table_row_height() * (old_position as f32 - new_position as f32);
1243 Some((row_id, displacement))
1244 });
1245 self.table
1246 .read(cx)
1247 .delegate()
1248 .row_reorder
1249 .project(travelled)
1250 } else {
1251 HashMap::new()
1252 };
1253 self.records = records;
1254 self.rows_by_id = accepted_rows_by_id;
1255 let desired_row_ix = self
1258 .selected_row_id
1259 .as_ref()
1260 .and_then(|selected| self.rows_by_id.position(selected))
1261 .filter(|row_ix| !self.records.content()[*row_ix].disabled);
1262 if self.selected_row_id.is_some() && desired_row_ix.is_none() {
1263 self.selected_row_id = None;
1264 }
1265
1266 let records = self.records.clone();
1267 let selected_row_id = self.selected_row_id.clone();
1268 if desired_row_ix.is_some() {
1269 self.pending_suppressed_selection_events =
1270 self.pending_suppressed_selection_events.saturating_add(1);
1271 }
1272 self.table.update(cx, |table, cx| {
1273 let delegate = table.delegate_mut();
1274 delegate.records = records;
1275 delegate.cell_provider = cell_provider;
1276 delegate.selected_row_id = selected_row_id.clone();
1277 delegate.row_reorder.accept(reorder_motion);
1278 table.refresh(cx);
1279 if let Some(row_ix) = desired_row_ix {
1280 table.set_selected_row(row_ix, cx);
1281 } else {
1282 table.clear_selection(cx);
1283 }
1284 if let Some(row_ix) = anchor_row_ix {
1285 table.scroll_to_row(row_ix, cx);
1286 }
1287 cx.notify();
1288 });
1289 cx.notify();
1293 true
1294 }
1295
1296 pub fn set_selected_row(
1301 &mut self,
1302 row_id: impl Into<SharedString>,
1303 _window: &mut Window,
1304 cx: &mut Context<Self>,
1305 ) {
1306 let row_id = row_id.into();
1307 let Some(row_ix) = self
1308 .rows_by_id
1309 .position(&row_id)
1310 .filter(|row_ix| !self.records.content()[*row_ix].disabled)
1311 else {
1312 return;
1313 };
1314
1315 self.selected_row_id = Some(row_id);
1316 self.pending_suppressed_selection_events =
1317 self.pending_suppressed_selection_events.saturating_add(1);
1318 let selected_row_id = self.selected_row_id.clone();
1319 self.table.update(cx, |table, cx| {
1320 table.delegate_mut().selected_row_id = selected_row_id;
1321 table.set_selected_row(row_ix, cx);
1322 });
1323 }
1324
1325 pub fn clear_selected_row(&mut self, cx: &mut Context<Self>) {
1327 self.selected_row_id = None;
1328 self.table.update(cx, |table, cx| {
1329 table.delegate_mut().selected_row_id = None;
1330 table.clear_selection(cx);
1331 });
1332 }
1333
1334 pub fn selected_row_id(&self) -> Option<&str> {
1336 self.selected_row_id.as_deref()
1337 }
1338
1339 pub fn set_sort(
1346 &mut self,
1347 column_id: impl Into<SharedString>,
1348 direction: Option<RecordSortDirection>,
1349 _window: &mut Window,
1350 cx: &mut Context<Self>,
1351 ) {
1352 let column_id = column_id.into();
1353 if direction.is_some()
1354 && !self
1355 .columns_by_id
1356 .position(&column_id)
1357 .is_some_and(|col_ix| self.columns[col_ix].sortable)
1358 {
1359 return;
1360 }
1361
1362 self.sort_column_id = direction.map(|_| column_id.clone());
1363 self.sort_direction = direction;
1364 let sort_column_id = self.sort_column_id.clone();
1365 self.table.update(cx, |table, cx| {
1366 table.delegate_mut().sort_column_id = sort_column_id;
1367 table.delegate_mut().sort_direction = direction;
1368 cx.notify();
1369 });
1370 }
1371
1372 pub fn sort(&self) -> Option<(&str, RecordSortDirection)> {
1374 self.sort_column_id.as_deref().zip(self.sort_direction)
1375 }
1376
1377 pub fn scroll_to_row(&mut self, row_id: &str, cx: &mut Context<Self>) {
1379 let Some(row_ix) = self.rows_by_id.position(row_id) else {
1380 return;
1381 };
1382 self.table
1383 .update(cx, |table, cx| table.scroll_to_row(row_ix, cx));
1384 self.viewport_row_anchor_id = Some(row_id.into());
1385 }
1386
1387 pub fn scroll_to_column(&mut self, column_id: &str, cx: &mut Context<Self>) {
1389 let Some(col_ix) = self.columns_by_id.position(column_id) else {
1390 return;
1391 };
1392 self.table
1393 .update(cx, |table, cx| table.scroll_to_col(col_ix, cx));
1394 self.viewport_column_anchor_id = Some(column_id.into());
1395 }
1396
1397 pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
1399 self.table.focus_handle(cx).focus(window, cx);
1400 }
1401
1402 fn resolve_layout(&mut self, rem_size: Pixels, cx: &mut Context<Self>) {
1408 if !self.resolved_layout.observe(rem_size) {
1409 return;
1410 }
1411 let widths_follow_the_rem = self
1416 .columns
1417 .iter()
1418 .any(|column| matches!(column.width, RecordColumnWidth::Rems(_)));
1419 self.table.update(cx, |table, _| {
1420 table.delegate_mut().rem_size = rem_size;
1421 });
1422 if widths_follow_the_rem || self.fit == ColumnFit::Fill {
1425 self.divide_columns(cx);
1426 }
1427 }
1428
1429 fn handle_table_event(&mut self, event: &TableEvent, cx: &mut Context<Self>) {
1430 match event {
1431 TableEvent::SelectRow(_) if self.pending_suppressed_selection_events > 0 => {
1432 self.pending_suppressed_selection_events =
1433 self.pending_suppressed_selection_events.saturating_sub(1);
1434 }
1435 TableEvent::SelectRow(row_ix) => {
1436 if let Some(row) = self.records.content().get(*row_ix) {
1437 let from_pointer = self.pending_pointer_row_id.take().as_ref() == Some(&row.id);
1438 if !row.disabled {
1439 cx.emit(RecordsTableEvent::SelectionRequested {
1440 id: self.id.clone(),
1441 row_id: row.id.clone(),
1442 });
1443 } else if !from_pointer {
1444 self.request_enabled_beyond(*row_ix, cx);
1445 }
1446 }
1447 self.defer_controlled_selection_sync(cx);
1448 }
1449 TableEvent::ClearSelection => {
1450 self.defer_controlled_selection_sync(cx);
1456 }
1457 TableEvent::DoubleClickedRow(row_ix) => {
1458 if let Some(row) = self.records.content().get(*row_ix)
1459 && !row.disabled
1460 {
1461 cx.emit(RecordsTableEvent::ActivationRequested {
1462 id: self.id.clone(),
1463 row_id: row.id.clone(),
1464 });
1465 }
1466 }
1467 _ => {}
1468 }
1469 }
1470
1471 fn defer_controlled_selection_sync(&self, cx: &mut Context<Self>) {
1472 let owner = cx.weak_entity();
1473 cx.defer(move |cx| {
1474 let _ = owner.update(cx, |table, cx| table.sync_controlled_selection(cx));
1475 });
1476 }
1477
1478 fn sync_controlled_selection(&mut self, cx: &mut Context<Self>) {
1479 let desired_row_ix = self
1480 .selected_row_id
1481 .as_ref()
1482 .and_then(|selected| self.rows_by_id.position(selected));
1483 let current_row_ix = self.table.read(cx).selected_row();
1484 if desired_row_ix == current_row_ix {
1485 return;
1486 }
1487
1488 if desired_row_ix.is_some() {
1489 self.pending_suppressed_selection_events =
1490 self.pending_suppressed_selection_events.saturating_add(1);
1491 }
1492 self.table.update(cx, |table, cx| {
1493 if let Some(row_ix) = desired_row_ix {
1494 table.set_selected_row(row_ix, cx);
1495 } else if table.selected_row().is_some() {
1496 table.clear_selection(cx);
1497 }
1498 });
1499 }
1500
1501 fn request_sort(&mut self, column_id: SharedString, cx: &mut Context<Self>) {
1502 let direction = if self.sort_column_id.as_ref() == Some(&column_id) {
1503 match self.sort_direction {
1504 None => Some(RecordSortDirection::Descending),
1505 Some(RecordSortDirection::Descending) => Some(RecordSortDirection::Ascending),
1506 Some(RecordSortDirection::Ascending) => None,
1507 }
1508 } else {
1509 Some(RecordSortDirection::Descending)
1510 };
1511
1512 cx.emit(RecordsTableEvent::SortRequested {
1513 id: self.id.clone(),
1514 column_id,
1515 direction,
1516 });
1517 }
1518
1519 fn request_activation(&mut self, row_id: SharedString, cx: &mut Context<Self>) {
1520 self.pending_pointer_row_id = None;
1521 if self
1522 .rows_by_id
1523 .position(&row_id)
1524 .is_some_and(|row_ix| !self.records.content()[row_ix].disabled)
1525 {
1526 cx.emit(RecordsTableEvent::ActivationRequested {
1527 id: self.id.clone(),
1528 row_id,
1529 });
1530 }
1531 }
1532
1533 fn activate_selected(&mut self, _: &ActivateRecord, _: &mut Window, cx: &mut Context<Self>) {
1534 let Some(row_id) = self.selected_row_id.clone() else {
1535 cx.propagate();
1536 return;
1537 };
1538 if self
1539 .rows_by_id
1540 .position(&row_id)
1541 .is_none_or(|row_ix| self.records.content()[row_ix].disabled)
1542 {
1543 cx.propagate();
1544 return;
1545 }
1546 cx.emit(RecordsTableEvent::ActivationRequested {
1547 id: self.id.clone(),
1548 row_id,
1549 });
1550 cx.stop_propagation();
1551 }
1552
1553 fn request_enabled_beyond(&self, disabled_index: usize, cx: &mut Context<Self>) {
1554 let current = self
1555 .selected_row_id
1556 .as_ref()
1557 .and_then(|selected| self.rows_by_id.position(selected));
1558 let forward = current.is_none_or(|current| disabled_index > current);
1559 let next = if forward {
1560 self.records
1561 .content()
1562 .iter()
1563 .enumerate()
1564 .skip(disabled_index.saturating_add(1))
1565 .find(|(_, row)| !row.disabled)
1566 } else {
1567 self.records
1568 .content()
1569 .iter()
1570 .enumerate()
1571 .take(disabled_index)
1572 .rev()
1573 .find(|(_, row)| !row.disabled)
1574 };
1575
1576 let Some((_, row)) = next else {
1577 return;
1578 };
1579 cx.emit(RecordsTableEvent::SelectionRequested {
1580 id: self.id.clone(),
1581 row_id: row.id.clone(),
1582 });
1583 }
1584}
1585
1586impl EventEmitter<RecordsTableEvent> for RecordsTable {}
1587
1588impl Focusable for RecordsTable {
1589 fn focus_handle(&self, cx: &App) -> FocusHandle {
1590 self.table.focus_handle(cx)
1591 }
1592}
1593
1594impl gpui::Styled for RecordsTable {
1595 fn style(&mut self) -> &mut gpui::StyleRefinement {
1596 &mut self.style
1597 }
1598}
1599
1600impl Render for RecordsTable {
1601 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
1602 let rem_size = window.rem_size();
1606 if !self.resolved_layout.matches(rem_size) {
1607 cx.defer_in(window, move |table, _, cx| {
1608 table.resolve_layout(rem_size, cx);
1609 });
1610 }
1611 if !self.columns_are_divided() {
1614 cx.defer_in(window, move |table, _, cx| {
1615 table.divide_columns(cx);
1616 });
1617 }
1618
1619 let inline_status = (!self.records.content().is_empty())
1620 .then(|| match self.records.state() {
1621 ProgressState::Pending | ProgressState::Running => {
1622 Some(records_inline_state_frame(
1623 &self.id,
1624 "records-loading",
1625 Role::ProgressIndicator,
1626 "Loading records".into(),
1627 cx,
1628 ))
1629 }
1630 ProgressState::Failed(reason) => Some(records_inline_state_frame(
1631 &self.id,
1632 "records-error",
1633 Role::Alert,
1634 format!("Records unavailable: {reason}").into(),
1635 cx,
1636 )),
1637 ProgressState::Complete => None,
1638 })
1639 .flatten();
1640 records_table_frame(self.id.clone(), self.label.clone())
1641 .flex()
1642 .flex_col()
1643 .min_h_0()
1644 .key_context(RECORDS_TABLE_CONTEXT)
1645 .text_color(cx.theme().foreground)
1652 .border_1()
1653 .border_color(cx.theme().transparent)
1654 .track_focus(&self.table.focus_handle(cx))
1655 .focus_visible(|style| style.border_color(cx.theme().ring))
1656 .on_action(cx.listener(Self::activate_selected))
1657 .when_some(inline_status, |this, status| this.child(status))
1658 .child(
1659 div()
1667 .flex_1()
1668 .min_h_0()
1669 .rounded(cx.theme().radius)
1676 .border_1()
1677 .border_color(cx.theme().border)
1678 .child(measured_grid_width(cx.weak_entity()))
1681 .child(
1682 DataTable::new(&self.table)
1683 .stripe(false)
1684 .bordered(false)
1685 .when_some(self.row_height, |grid, height| {
1686 grid.with_size(Size::Size(height))
1687 }),
1688 ),
1689 )
1690 .when_some(self.footer.clone(), |frame, footer| {
1691 frame.child(
1698 gpui::deferred(records_table_footer(&self.id, footer, cx)).with_priority(1),
1699 )
1700 })
1701 .refine_style(&self.style)
1702 }
1703}
1704
1705#[cfg(test)]
1706mod tests;