Skip to main content

gpui_ai/
records_table.rs

1//! Controlled record-grid values and presentation.
2
3use 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        /// Activates the consumer-controlled selected record.
41        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/// Sort direction requested for a record column.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum RecordSortDirection {
55    /// Sort values from low to high.
56    Ascending,
57    /// Sort values from high to low.
58    Descending,
59}
60
61/// When a row's activation control is visible.
62///
63/// The default reveals it on the row's hover — and always on the
64/// control's own keyboard focus, so reachability never depends on the
65/// pointer. `Always` keeps it painted for consumers whose rows are
66/// action-first.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum RowActionVisibility {
69    /// Painted on every row, all the time.
70    Always,
71    /// Revealed by row hover and by the control's keyboard focus.
72    #[default]
73    OnHover,
74}
75
76/// Where a row's activation control sits in its cell.
77///
78/// The default aligns it to the trailing edge, so the actions rail down
79/// the column; `Inline` keeps it beside the content for rows whose
80/// action reads as part of the label.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum RowActionPlacement {
83    /// Immediately after the cell's content.
84    Inline,
85    /// Right-aligned at the trailing edge of the cell.
86    #[default]
87    End,
88}
89
90/// Horizontal alignment for one records column.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub enum RecordColumnAlignment {
93    /// Align content to the leading edge.
94    #[default]
95    Left,
96    /// Center content in the column.
97    Center,
98    /// Align content to the trailing edge.
99    Right,
100}
101
102/// Visual structure of one record cell.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub enum RecordCellKind {
105    /// A change from one value to another: a tone glyph beside the struck
106    /// previous value, an arrow, and the new value.
107    Change,
108    /// Ordinary readable text.
109    #[default]
110    Text,
111    /// A set of compact categorical tags.
112    Tags,
113    /// A named semantic status.
114    Status,
115}
116
117/// Semantic emphasis for a status cell.
118///
119/// The readable status text always carries meaning; tone only reinforces it.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum RecordStatusTone {
122    /// Positive or healthy status.
123    Positive,
124    /// Informational status without positive or negative meaning.
125    Neutral,
126    /// Status that deserves attention.
127    Caution,
128    /// Critical or failed status.
129    Critical,
130}
131
132/// Typed application intent emitted by [`RecordsTable`].
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum RecordsTableEvent {
135    /// Requests that the application select the identified row.
136    SelectionRequested {
137        /// Stable records-table ID.
138        id: SharedString,
139        /// Stable application row ID.
140        row_id: SharedString,
141    },
142    /// Requests the row's primary application action.
143    ActivationRequested {
144        /// Stable records-table ID.
145        id: SharedString,
146        /// Stable application row ID.
147        row_id: SharedString,
148    },
149    /// Requests a controlled sort projection.
150    SortRequested {
151        /// Stable records-table ID.
152        id: SharedString,
153        /// Stable application column ID.
154        column_id: SharedString,
155        /// Requested direction, or `None` to clear sorting.
156        direction: Option<RecordSortDirection>,
157    },
158}
159
160/// How wide a column asked to be, in whichever unit its caller chose.
161///
162/// Pixels stay pixels: a caller who measured something owns that number. A rem
163/// width is a multiple of the reader's base type size and becomes pixels only
164/// at layout, so a column sized for its text keeps that proportion when the
165/// reader zooms. Private because the choice is the column's own business —
166/// what a table consumes is the resolved width.
167#[derive(Debug, Clone, Copy, Default, PartialEq)]
168enum RecordColumnWidth {
169    /// No configured width; the table's own default applies.
170    #[default]
171    Unset,
172    /// A device-independent width that does not follow the type scale.
173    Pixels(Pixels),
174    /// A width in multiples of the window's rem.
175    Rems(Rems),
176}
177
178impl RecordColumnWidth {
179    /// The pixel width for `rem_size`, or `None` to leave the choice upstream.
180    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/// How a table divides the width it is given.
190///
191/// A table is nearly always given more width than the columns asked for, and
192/// what it does with the remainder is the difference between a table and a
193/// picture of one. HTML settled this a long time ago: a table fills its box,
194/// specified widths are honoured, and the surplus goes to whoever will take
195/// it. This is that rule.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
197pub enum ColumnFit {
198    /// Columns divide the table's whole width; the default.
199    ///
200    /// The surplus over what the columns asked for goes to the columns that
201    /// asked to grow with [`RecordColumn::fill`], or - when none did - to all
202    /// of them in proportion to the widths they already have. Either way the
203    /// table ends where its frame ends rather than leaving a band of nothing
204    /// beside the last column.
205    #[default]
206    Fill,
207    /// Columns keep exactly the widths they were given.
208    ///
209    /// The table ends where its columns end, and the reader may drag a column
210    /// edge: a width the table computes cannot also be a width the reader
211    /// chooses, so dragging belongs to this mode alone.
212    Intrinsic,
213}
214
215/// Measures the box the grid is drawn in, so its columns can divide it.
216///
217/// A canvas rather than a wrapper, so nothing is added to the layout: it
218/// reports the box it was placed in and draws nothing. The owning table
219/// ignores a width it has already seen, so the frame this asks for on a resize
220/// is one frame, not every frame.
221fn 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
232/// Every column's pixel width, for a table with this much room.
233///
234/// The rule, in three lines: a column's base is the width it asked for; the
235/// surplus over the sum of those bases belongs to the columns that asked to
236/// grow, or to all of them in proportion when none did; and a table with no
237/// surplus is a table whose columns are exactly their bases, which is where
238/// the horizontal scroll takes over.
239///
240/// `available` is the room the columns may occupy, which is not the whole of
241/// the box the grid was given: see [`RecordsTable::push_column_widths`] for
242/// what comes off it first.
243///
244/// Nothing here can shrink a column, so nothing here can collapse one. That is
245/// the whole reason the floor needs no separate setting - the collapse other
246/// libraries guard against with a minimum width is not reachable from here.
247fn 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    // Whoever asked, or everyone in proportion. A share of zero is a column
271    // saying it will stay where it is while its neighbours grow.
272    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
293/// The width a column that named none is given.
294///
295/// Read from upstream rather than restated, so the width a column falls back to
296/// here and the width the grid would have drawn cannot drift apart.
297fn default_column_width() -> Pixels {
298    gpui_component::table::Column::default().width
299}
300
301/// One stable column in a [`RecordsTable`].
302///
303/// Visible labels need not be unique. Events and row lookup always use the
304/// application-supplied column ID.
305#[derive(Debug, Clone, PartialEq)]
306pub struct RecordColumn {
307    id: SharedString,
308    label: SharedString,
309    sortable: bool,
310    width: RecordColumnWidth,
311    /// The share of the table's surplus width this column takes, when it asked
312    /// for one. `None` is a column that did not ask.
313    fill: Option<f32>,
314    alignment: RecordColumnAlignment,
315    fixed: bool,
316    description: Option<SharedString>,
317}
318
319impl RecordColumn {
320    /// Creates a column with a stable application ID and visible label.
321    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    /// Enables or disables sorting for the column.
335    pub fn sortable(mut self, sortable: bool) -> Self {
336        self.sortable = sortable;
337        self
338    }
339
340    /// Sets the logical column width.
341    pub fn width(mut self, width: Pixels) -> Self {
342        self.width = RecordColumnWidth::Pixels(width);
343        self
344    }
345
346    /// Sets the column width in multiples of the reader's base type size.
347    ///
348    /// The width resolves against the window's rem at layout time and is
349    /// resolved again when the reader zooms, so a column sized for its text
350    /// keeps that proportion; a width set with [`RecordColumn::width`] does
351    /// not. Columns of one table may mix the two, and the later call wins.
352    pub fn width_in_rems(mut self, width: f32) -> Self {
353        self.width = RecordColumnWidth::Rems(rems(width));
354        self
355    }
356
357    /// Takes a share of the width left over once every column has the width
358    /// it asked for.
359    ///
360    /// The width set by [`RecordColumn::width`] is a floor rather than a
361    /// ceiling here: the surplus is added to it, so a column that fills can
362    /// never collapse and there is no separate minimum to remember. A column
363    /// with twice the share of another takes twice as much of the surplus.
364    ///
365    /// The moment one column asks, it and its fellow askers divide the surplus
366    /// and every other column keeps exactly what it asked for - which is how a
367    /// table gets one exact column beside one that stretches.
368    ///
369    /// ```
370    /// # use gpui::px;
371    /// # use gpui_ai::records_table::RecordColumn;
372    /// let exact = RecordColumn::new("cost", "Unit cost").width(px(120.));
373    /// let stretches = RecordColumn::new("notes", "Notes").width(px(180.)).fill(1.);
374    /// ```
375    pub fn fill(mut self, share: f32) -> Self {
376        self.fill = Some(share.max(0.));
377        self
378    }
379
380    /// Sets the column's horizontal content alignment.
381    pub fn alignment(mut self, alignment: RecordColumnAlignment) -> Self {
382        self.alignment = alignment;
383        self
384    }
385
386    /// Pins or unpins the column at the leading edge.
387    pub fn fixed(mut self, fixed: bool) -> Self {
388        self.fixed = fixed;
389        self
390    }
391
392    /// Adds supplementary accessible column guidance.
393    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
394        self.description = Some(description.into());
395        self
396    }
397
398    /// Returns the stable application column ID.
399    pub fn id(&self) -> &str {
400        self.id.as_ref()
401    }
402
403    /// Returns the visible column label.
404    pub fn label(&self) -> &str {
405        self.label.as_ref()
406    }
407
408    /// Returns whether the column can request sorting.
409    pub fn is_sortable(&self) -> bool {
410        self.sortable
411    }
412
413    /// Returns the explicitly configured logical column width, if any.
414    ///
415    /// A width set with [`RecordColumn::width_in_rems`] has no pixel value
416    /// until a window resolves it, so it reads as `None` here.
417    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    /// Returns the horizontal content alignment.
425    pub fn column_alignment(&self) -> RecordColumnAlignment {
426        self.alignment
427    }
428
429    /// Returns whether the column is pinned to the leading edge.
430    pub fn is_fixed(&self) -> bool {
431        self.fixed
432    }
433
434    /// Returns supplementary accessible guidance, if supplied.
435    pub fn accessible_description(&self) -> Option<&str> {
436        self.description.as_deref()
437    }
438}
439
440/// One readable record value associated with a stable column ID.
441#[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    /// Creates a cell for the identified column.
453    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    /// Creates a categorical tag cell.
465    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    /// Creates a cell describing a change from one value to another.
486    ///
487    /// The change kind is carried by the tone glyph, with the struck
488    /// previous value, an arrow, and the new value as one readable run —
489    /// never a prose prefix that wraps and clips. An addition passes no
490    /// `before`, a removal no `after`; the accessible value reads as a
491    /// sentence either way.
492    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    /// The previous value of a change cell, when the change kept one.
509    pub fn change_before(&self) -> Option<&SharedString> {
510        self.change_before.as_ref()
511    }
512
513    /// The value assistive technology reads for this cell.
514    ///
515    /// A change cell reads as a sentence — "was A, now B" — instead of
516    /// leaking its visual arrow and strikethrough.
517    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    /// Creates a readable semantic status cell.
531    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    /// Returns the stable column ID associated with this value.
547    pub fn column_id(&self) -> &str {
548        self.column_id.as_ref()
549    }
550
551    /// Returns the readable cell value.
552    pub fn value(&self) -> &str {
553        self.value.as_ref()
554    }
555
556    /// Returns the cell's typed visual structure.
557    pub fn kind(&self) -> RecordCellKind {
558        self.kind
559    }
560
561    /// Returns categorical tags for a tag cell.
562    pub fn tag_values(&self) -> &[SharedString] {
563        &self.tags
564    }
565
566    /// Returns the semantic status tone for a status cell.
567    pub fn status_tone(&self) -> Option<RecordStatusTone> {
568        self.status_tone
569    }
570}
571
572/// One immutable record row keyed by a stable application ID.
573#[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    /// Creates an empty row with a stable ID and accessible label.
583    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    /// Replaces the row's immutable cell snapshot.
593    pub fn cells(mut self, cells: impl IntoIterator<Item = RecordCell>) -> Self {
594        self.cells = cells.into_iter().collect();
595        self
596    }
597
598    /// Sets whether the row rejects selection and activation intent.
599    pub fn disabled(mut self, disabled: bool) -> Self {
600        self.disabled = disabled;
601        self
602    }
603
604    /// Returns the stable application row ID.
605    pub fn id(&self) -> &str {
606        self.id.as_ref()
607    }
608
609    /// Returns the row's accessible label.
610    pub fn label(&self) -> &str {
611        self.label.as_ref()
612    }
613
614    /// Returns the cell associated with `column_id`, if present.
615    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    /// Returns the immutable cell snapshot in consumer order.
622    pub fn all_cells(&self) -> &[RecordCell] {
623        &self.cells
624    }
625
626    /// Returns whether the row rejects selection and activation intent.
627    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
684/// The strip along the bottom of a table.
685///
686/// Outside the table's own border rather than inside it: the border belongs to
687/// the scrolling body, and a summary that scrolled away with the rows would be
688/// a row. Sitting under it it reads as the third part of the table - and gives
689/// a frame taller than its rows a bottom that means something.
690///
691/// Inset to the same column as the first cell above it, so the count sits
692/// under what it counts.
693fn 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
728/// A glyph that restates the state family without color: a spinner for
729/// in-flight work, a cross for failures, a dash for nothing to show.
730fn 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
738/// Loading labels shimmer; settled states stay selectable prose.
739fn 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
784/// A controlled, virtualized records grid built on gpui-component's table.
785///
786/// Applications own columns, records, progress, sorting, and the selected row
787/// ID. The entity retains only upstream focus and scrolling state.
788pub struct RecordsTable {
789    /// Styles the caller put on this component, applied to its own frame.
790    ///
791    /// Last, so a caller outranks the component's defaults - the same rule the
792    /// builder components follow. A wrapper `div` cannot stand in for this:
793    /// a background, a border, or an ink set on a wrapper paints around the
794    /// component rather than on it.
795    style: gpui::StyleRefinement,
796    id: SharedString,
797    label: SharedString,
798    /// A summary strip along the bottom of the frame, when the application
799    /// gives one.
800    ///
801    /// A table's third part, after its header and its body. It is what stops a
802    /// frame taller than its rows from reading as rows that have been cut off:
803    /// with a bottom that says something - a count, a total - the space above
804    /// it is a body with room left in it, which is what it is.
805    footer: Option<SharedString>,
806    /// How the table divides the width it is given.
807    fit: ColumnFit,
808    /// The height every row is drawn at, when the application names one.
809    row_height: Option<Pixels>,
810    /// The width the grid was last drawn at, which is the only thing a filling
811    /// column has to divide.
812    ///
813    /// Kept apart from [`ResolvedLayoutKey`], which is for what the *window*
814    /// resolves: a rem change invalidates every measured row height in the
815    /// crate, while this invalidates one table's column widths.
816    measured_width: Option<Pixels>,
817    /// The width the columns in force were divided from, so a frame can tell
818    /// whether the division it is looking at is the current one.
819    divided_width: Option<Pixels>,
820    columns: Arc<[RecordColumn]>,
821    records: Progressive<Arc<[RecordRow]>>,
822    /// Rebuilt with `records`; never read against any other snapshot.
823    rows_by_id: StableIdIndex,
824    /// Rebuilt with `columns`; never read against any other snapshot.
825    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    /// Creates an empty records table with a stable ID and accessible label.
841    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        // The first frame already has a rem to resolve against, so a column
851        // width given in rems is right on the frame it first appears rather
852        // than being corrected after one.
853        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    /// Sets when every row's activation control is visible.
900    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    /// Sets where every row's activation control sits in its cell.
912    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    /// Replaces the visible and accessible verb used by row activation controls.
924    ///
925    /// The verb reaches the screen through the delegate, and this entity's own
926    /// `render` never reads it, so only the table is notified: the window is
927    /// invalidated either way, and a second notification would wake
928    /// application observers of a value they already own.
929    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    /// Sets the summary strip along the bottom of the table, or clears it.
962    ///
963    /// Controlled like every other input here: the application owns the text,
964    /// and the table renders whatever it is given. A row count is the usual
965    /// one; a total or a filter summary is as good.
966    ///
967    /// ```
968    /// # use gpui_ai::records_table::RecordsTable;
969    /// # fn example(table: &mut RecordsTable, cx: &mut gpui::Context<RecordsTable>) {
970    /// table.set_footer(Some("4 suppliers".into()), cx);
971    /// # }
972    /// ```
973    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    /// Sets how the table divides the width it is given.
981    ///
982    /// [`ColumnFit::Fill`] by default, which is what a reader expects of a
983    /// table: it ends where its frame ends. [`ColumnFit::Intrinsic`] keeps
984    /// every column at exactly the width it was given and hands column widths
985    /// back to the reader's own drag.
986    ///
987    /// ```
988    /// # use gpui_ai::records_table::{ColumnFit, RecordsTable};
989    /// # fn example(table: &mut RecordsTable, cx: &mut gpui::Context<RecordsTable>) {
990    /// table.set_column_fit(ColumnFit::Intrinsic, cx);
991    /// # }
992    /// ```
993    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    /// Returns how the table divides the width it is given.
1002    pub fn column_fit(&self) -> ColumnFit {
1003        self.fit
1004    }
1005
1006    /// Sets the height every row is drawn at, or restores the table's own.
1007    ///
1008    /// A row's height is the table's rhythm, and an application that has
1009    /// measured its own content may name it exactly. `None` returns the rows
1010    /// to the height the size policy gives them.
1011    ///
1012    /// ```
1013    /// # use gpui::px;
1014    /// # use gpui_ai::records_table::RecordsTable;
1015    /// # fn example(table: &mut RecordsTable, cx: &mut gpui::Context<RecordsTable>) {
1016    /// table.set_row_height(Some(px(44.)), cx);
1017    /// # }
1018    /// ```
1019    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    /// Returns the height every row is drawn at, when one was named.
1028    pub fn row_height(&self) -> Option<Pixels> {
1029        self.row_height
1030    }
1031
1032    /// Hands the grid the width every column should now be drawn at.
1033    ///
1034    /// Upstream caches column widths in pixels when it builds its column
1035    /// groups, so a width that changed only reaches layout through a refresh.
1036    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            // The overlaid bar's lane comes off the top. It has to: a bar that
1041            // takes no layout space would otherwise sit over the last column's
1042            // content, and columns that divide the box exactly leave the grid
1043            // balanced on the edge of needing a horizontal scrollbar, which it
1044            // then decides again every frame and never settles. Under a gutter
1045            // policy this is zero, because the gutter has already taken it.
1046            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    /// Records the width the grid was drawn at.
1058    ///
1059    /// Called from the grid's own prepaint, where it may record and nothing
1060    /// else. Dividing the columns here would rebuild the grid's column groups
1061    /// in the middle of the frame that is measuring them, and the frame never
1062    /// settles: nineteen thousand prepaints for one draw, which is how this was
1063    /// found. The division is deferred out of the frame instead, the same way
1064    /// a new rem is, and the width that was divided is remembered so the next
1065    /// frame knows there is nothing left to do.
1066    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    /// Whether the columns have been divided from the width last measured.
1075    fn columns_are_divided(&self) -> bool {
1076        self.fit == ColumnFit::Intrinsic || self.divided_width == self.measured_width
1077    }
1078
1079    /// Divides the columns from the width last measured.
1080    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    /// Replaces the controlled column snapshot without rebuilding table state.
1086    ///
1087    /// A snapshot containing duplicate stable column IDs is ignored atomically.
1088    /// Columns are drawn entirely by the table, so only the table is notified.
1089    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        // New columns, new division: the widths cached above belong to the
1140        // snapshot that just left.
1141        self.divide_columns(cx);
1142    }
1143
1144    /// Replaces the controlled progressive record snapshot.
1145    ///
1146    /// A snapshot containing duplicate row IDs or duplicate cell column IDs
1147    /// within one row is ignored atomically.
1148    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        // The validating pass answers every "where is this ID now?" below:
1172        // anchor recovery, the reorder window, selection, and the scroll
1173        // restore. The retained index still describes the outgoing snapshot
1174        // until this one replaces it, which is what makes the displacement
1175        // math a pair of lookups instead of a pair of scans.
1176        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        // Where the viewport lands in the accepted snapshot: the anchor row if
1210        // it survived, else the earliest surviving row that was on screen. The
1211        // reorder window below and the scroll restore afterwards are the same
1212        // recovery, so they share one answer rather than repeating it.
1213        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            // How far a row that stays on screen travels between the two
1231            // snapshots is viewport geometry, and it is all the reorder owner
1232            // needs: the spring decisions the travel implies are its own.
1233            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        // A selected row that the snapshot dropped, or now disables, is no
1256        // longer selectable, so the controlled value clears with it.
1257        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        // Both entities really do change here: the table draws the rows, and
1290        // this entity draws the progress and failure banner beside them from
1291        // the same snapshot's lifecycle state.
1292        cx.notify();
1293        true
1294    }
1295
1296    /// Replaces the controlled selected row when the ID exists.
1297    ///
1298    /// The selection is drawn by the table's own rows, so only the table is
1299    /// notified; the reader for the controlled value is a plain accessor.
1300    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    /// Clears the controlled selected-row snapshot.
1326    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    /// Returns the controlled selected row ID.
1335    pub fn selected_row_id(&self) -> Option<&str> {
1336        self.selected_row_id.as_deref()
1337    }
1338
1339    /// Replaces the controlled sort snapshot.
1340    ///
1341    /// Passing `None` clears sorting. A non-sortable or unknown column ID is
1342    /// ignored so stale application snapshots cannot corrupt table state.
1343    /// The sort marker lives in the table's own header, so only the table is
1344    /// notified.
1345    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    /// Returns the controlled sort column ID and direction.
1373    pub fn sort(&self) -> Option<(&str, RecordSortDirection)> {
1374        self.sort_column_id.as_deref().zip(self.sort_direction)
1375    }
1376
1377    /// Scrolls the identified record into view when it exists.
1378    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    /// Scrolls the identified column into view when it exists.
1388    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    /// Moves keyboard focus to the records grid.
1398    pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
1399        self.table.focus_handle(cx).focus(window, cx);
1400    }
1401
1402    /// Re-resolves rem-scaled column widths after the window's rem changed.
1403    ///
1404    /// Upstream caches each column's width in pixels when the table refreshes,
1405    /// and no record snapshot reports a zoom, so without this a table sized in
1406    /// rems keeps the widths it resolved at the reader's previous type scale.
1407    fn resolve_layout(&mut self, rem_size: Pixels, cx: &mut Context<Self>) {
1408        if !self.resolved_layout.observe(rem_size) {
1409            return;
1410        }
1411        // Refreshing rebuilds every column's cached width, so a table whose
1412        // columns are all in pixels keeps whatever widths it has — including
1413        // any the reader dragged — while still carrying the new rem forward
1414        // for a column snapshot that arrives later.
1415        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        // A filling table divides again whatever its columns are measured in:
1423        // the bases moved, so the surplus did.
1424        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                // Escape reaches upstream's Cancel action, which drops its own
1451                // selection and tells us here. Selection is application-owned,
1452                // so re-assert the controlled value instead of letting the
1453                // rendered row and `selected_row_id` disagree — otherwise the
1454                // highlight vanishes while Enter still activates the old row.
1455                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        // A column width given in rems is only as current as the rem it was
1603        // resolved against. Reading it here mutates nothing; the reaction is
1604        // deferred so that render itself neither refreshes nor notifies.
1605        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        // And the same for the width the columns divide, for the same reason:
1612        // reading it here mutates nothing.
1613        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            // The component's own ink. Everything inside the grid is drawn by
1646            // the table, which colours itself, so nothing here needed saying
1647            // until the footer put the crate's own text in this frame - and
1648            // inherited GPUI's default, which is white, and vanished against
1649            // the surface. A component sets the ink it draws in rather than
1650            // trusting whatever it was dropped into.
1651            .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                // Upstream pads a striped table with empty rows to cover
1660                // whatever space is left over, so a table stretched to a
1661                // container taller than its rows grows a band of blank stripes
1662                // with the last one clipped by the edge - rows that appear to
1663                // be there and cannot be reached. Unstriped, the leftover
1664                // space is plainly leftover space, and the footer below says
1665                // where the body ends.
1666                div()
1667                    .flex_1()
1668                    .min_h_0()
1669                    // The frame around the grid is drawn here rather than by
1670                    // the grid, in the same ink and radius upstream would have
1671                    // used. It has to be ours because the measurement below is
1672                    // taken inside it: a column dividing the box the border is
1673                    // drawn on divides two pixels the grid never gets, and the
1674                    // table would overflow itself by exactly its own edge.
1675                    .rounded(cx.theme().radius)
1676                    .border_1()
1677                    .border_color(cx.theme().border)
1678                    // What a filling column divides: the box the grid is given,
1679                    // and the only one a column can be a share of.
1680                    .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                // Painted after the grid, not merely placed below it.
1692                // Upstream draws the grid's horizontal bar in an overlay that
1693                // hangs past the bottom of the box the grid was given, and a
1694                // strip laid out underneath is drawn over and never seen. A
1695                // deferred child keeps its place in the layout and paints last,
1696                // which is the whole of what this needs.
1697                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;