Skip to main content

gpui_ai/
diff_table.rs

1//! Controlled before/after proposals for tabular data.
2
3use gpui_base::StyledExt as _;
4use std::{
5    collections::HashSet,
6    sync::{
7        Arc,
8        atomic::{AtomicUsize, Ordering},
9    },
10};
11
12use gpui::{
13    App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement as _,
14    ParentElement as _, Render, Role, SharedString, StatefulInteractiveElement as _, Styled as _,
15    Subscription, Window, div, prelude::FluentBuilder as _,
16};
17use gpui_component::{ActiveTheme as _, text::TextView};
18
19use crate::{
20    control::outlined_control_with_label,
21    records_table::{
22        RecordCell, RecordCellProvider, RecordColumn, RecordColumnAlignment, RecordRow,
23        RecordSortDirection, RecordStatusTone, RecordsTable, RecordsTableEvent,
24        escape_markdown_text, record_columns_have_unique_ids,
25    },
26    stream::{ProgressState, Progressive},
27};
28
29/// A stable, configurable column shared with the virtualized records adapter.
30pub type DiffColumn = RecordColumn;
31
32/// Horizontal alignment for one diff column.
33pub type DiffColumnAlignment = RecordColumnAlignment;
34
35/// Sort direction requested for a diff column.
36pub type DiffSortDirection = RecordSortDirection;
37
38/// The semantic relationship between a proposed value and its prior value.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DiffChangeKind {
41    /// A value exists only in the proposed snapshot.
42    Added,
43    /// A value exists only in the prior snapshot.
44    Removed,
45    /// Both snapshots contain different values.
46    Changed,
47    /// Both snapshots contain the same value.
48    Unchanged,
49}
50
51/// An application-owned proposal decision displayed by a diff row.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum DiffProposalState {
54    /// The proposal has not been decided.
55    Pending,
56    /// The application accepted the proposal.
57    Accepted,
58    /// The application rejected the proposal.
59    Rejected,
60}
61
62/// A decision intent emitted for an identified proposal.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum DiffProposalAction {
65    /// Request acceptance of the proposal.
66    Accept,
67    /// Request rejection of the proposal.
68    Reject,
69}
70
71/// One immutable before/after value associated with a stable column ID.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct DiffCell {
74    column_id: SharedString,
75    before: Option<SharedString>,
76    after: Option<SharedString>,
77    change_kind: DiffChangeKind,
78}
79
80impl DiffCell {
81    /// Creates a value that exists only in the proposed snapshot.
82    pub fn added(column_id: impl Into<SharedString>, after: impl Into<SharedString>) -> Self {
83        Self {
84            column_id: column_id.into(),
85            before: None,
86            after: Some(after.into()),
87            change_kind: DiffChangeKind::Added,
88        }
89    }
90
91    /// Creates a value that exists only in the prior snapshot.
92    pub fn removed(column_id: impl Into<SharedString>, before: impl Into<SharedString>) -> Self {
93        Self {
94            column_id: column_id.into(),
95            before: Some(before.into()),
96            after: None,
97            change_kind: DiffChangeKind::Removed,
98        }
99    }
100
101    /// Creates a value that differs between the prior and proposed snapshots.
102    pub fn changed(
103        column_id: impl Into<SharedString>,
104        before: impl Into<SharedString>,
105        after: impl Into<SharedString>,
106    ) -> Self {
107        Self {
108            column_id: column_id.into(),
109            before: Some(before.into()),
110            after: Some(after.into()),
111            change_kind: DiffChangeKind::Changed,
112        }
113    }
114
115    /// Creates a value that is identical in both snapshots.
116    pub fn unchanged(column_id: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
117        let value = value.into();
118        Self {
119            column_id: column_id.into(),
120            before: Some(value.clone()),
121            after: Some(value),
122            change_kind: DiffChangeKind::Unchanged,
123        }
124    }
125
126    /// Returns the stable column ID associated with this value.
127    pub fn column_id(&self) -> &str {
128        self.column_id.as_ref()
129    }
130
131    /// Returns the prior readable value, when one exists.
132    pub fn before(&self) -> Option<&str> {
133        self.before.as_deref()
134    }
135
136    /// Returns the proposed readable value, when one exists.
137    pub fn after(&self) -> Option<&str> {
138        self.after.as_deref()
139    }
140
141    /// Returns the semantic relationship between the two snapshots.
142    pub fn change_kind(&self) -> DiffChangeKind {
143        self.change_kind
144    }
145}
146
147/// One immutable proposal row keyed by a stable application ID.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct DiffRow {
150    id: SharedString,
151    label: SharedString,
152    change_kind: DiffChangeKind,
153    cells: Arc<[DiffCell]>,
154    proposal_state: DiffProposalState,
155    disabled: bool,
156}
157
158impl DiffRow {
159    /// Creates an empty proposal row with stable identity and semantic change kind.
160    pub fn new(
161        id: impl Into<SharedString>,
162        label: impl Into<SharedString>,
163        change_kind: DiffChangeKind,
164    ) -> Self {
165        Self {
166            id: id.into(),
167            label: label.into(),
168            change_kind,
169            cells: Arc::from([]),
170            proposal_state: DiffProposalState::Pending,
171            disabled: false,
172        }
173    }
174
175    /// Replaces the immutable before/after cell snapshot.
176    pub fn cells(mut self, cells: impl IntoIterator<Item = DiffCell>) -> Self {
177        self.cells = cells.into_iter().collect::<Vec<_>>().into();
178        self
179    }
180
181    /// Sets the application-owned proposal decision shown by this row.
182    pub fn state(mut self, proposal_state: DiffProposalState) -> Self {
183        self.proposal_state = proposal_state;
184        self
185    }
186
187    /// Sets whether the proposal rejects selection and action intent.
188    pub fn disabled(mut self, disabled: bool) -> Self {
189        self.disabled = disabled;
190        self
191    }
192
193    /// Returns the stable application proposal ID.
194    pub fn id(&self) -> &str {
195        self.id.as_ref()
196    }
197
198    /// Returns the accessible row label.
199    pub fn label(&self) -> &str {
200        self.label.as_ref()
201    }
202
203    /// Returns the row-level semantic change kind.
204    pub fn change_kind(&self) -> DiffChangeKind {
205        self.change_kind
206    }
207
208    /// Returns the value associated with `column_id`, if present.
209    pub fn cell(&self, column_id: &str) -> Option<&DiffCell> {
210        self.cells
211            .iter()
212            .find(|cell| cell.column_id.as_ref() == column_id)
213    }
214
215    /// Returns the immutable cell snapshot in consumer order.
216    pub fn all_cells(&self) -> &[DiffCell] {
217        &self.cells
218    }
219
220    /// Returns the application-owned proposal decision.
221    pub fn proposal_state(&self) -> DiffProposalState {
222        self.proposal_state
223    }
224
225    /// Returns whether the proposal rejects selection and action intent.
226    pub fn is_disabled(&self) -> bool {
227        self.disabled
228    }
229}
230
231fn diff_rows_have_unique_ids(rows: &[DiffRow]) -> bool {
232    let mut row_ids = HashSet::with_capacity(rows.len());
233    rows.iter().all(|row| {
234        let mut cell_ids = HashSet::with_capacity(row.cells.len());
235        row_ids.insert(row.id())
236            && row
237                .cells
238                .iter()
239                .all(|cell| cell_ids.insert(cell.column_id()))
240    })
241}
242
243/// Typed application intent emitted by [`DiffTable`].
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum DiffTableEvent {
246    /// Requests that the application select the identified proposal.
247    SelectionRequested {
248        /// Stable diff-table ID.
249        id: SharedString,
250        /// Stable application proposal ID.
251        row_id: SharedString,
252    },
253    /// Requests that the application reveal or inspect a proposal.
254    ReviewRequested {
255        /// Stable diff-table ID.
256        id: SharedString,
257        /// Stable application proposal ID.
258        row_id: SharedString,
259    },
260    /// Requests a controlled sort projection.
261    SortRequested {
262        /// Stable diff-table ID.
263        id: SharedString,
264        /// Stable application column ID.
265        column_id: SharedString,
266        /// Requested direction, or `None` to clear sorting.
267        direction: Option<DiffSortDirection>,
268    },
269    /// Requests a controlled decision for an identified proposal.
270    DecisionRequested {
271        /// Stable diff-table ID.
272        id: SharedString,
273        /// Stable application proposal ID.
274        row_id: SharedString,
275        /// Requested accept or reject intent.
276        action: DiffProposalAction,
277    },
278}
279
280/// A controlled, virtualized table of proposed before/after values.
281///
282/// Applications own columns, proposals, progress, sorting, selection, and
283/// proposal decisions. The entity composes [`RecordsTable`] for focus,
284/// virtualization, and scrolling, and retains no application work.
285pub struct DiffTable {
286    /// Styles the caller put on this component, applied to its own frame.
287    ///
288    /// Last, so a caller outranks the component's defaults - the same rule the
289    /// builder components follow. A wrapper `div` cannot stand in for this:
290    /// a background, a border, or an ink set on a wrapper paints around the
291    /// component rather than on it.
292    style: gpui::StyleRefinement,
293    id: SharedString,
294    label: SharedString,
295    columns: Arc<[DiffColumn]>,
296    rows: Progressive<Arc<[DiffRow]>>,
297    selected_row_id: Option<SharedString>,
298    sort_column_id: Option<SharedString>,
299    sort_direction: Option<DiffSortDirection>,
300    review_column_id: SharedString,
301    decision_column_id: SharedString,
302    projected_cells: Arc<AtomicUsize>,
303    records_table: gpui::Entity<RecordsTable>,
304    _records_subscription: Subscription,
305}
306
307impl DiffTable {
308    /// Creates an empty diff table with a stable ID and accessible label.
309    pub fn new(
310        id: impl Into<SharedString>,
311        label: impl Into<SharedString>,
312        window: &mut Window,
313        cx: &mut Context<Self>,
314    ) -> Self {
315        let id = id.into();
316        let label = label.into();
317        let records_id = scoped_diff_id("records", &id, "table");
318        let review_column_id = scoped_diff_id("review-column", &id, "action");
319        let decision_column_id = scoped_diff_id("decision-column", &id, "state");
320        let records_label = label.clone();
321        let records_table = cx.new(|cx| {
322            let mut table = RecordsTable::new(records_id, records_label, window, cx);
323            table.set_activation_label("Review", cx);
324            table
325        });
326        let records_subscription = cx.subscribe(&records_table, |this, _, event, cx| {
327            this.handle_records_event(event, cx);
328        });
329
330        Self {
331            style: gpui::StyleRefinement::default(),
332            id,
333            label,
334            columns: Arc::from([]),
335            rows: Progressive::pending(Arc::from([])),
336            selected_row_id: None,
337            sort_column_id: None,
338            sort_direction: None,
339            review_column_id,
340            decision_column_id,
341            projected_cells: Arc::new(AtomicUsize::new(0)),
342            records_table,
343            _records_subscription: records_subscription,
344        }
345    }
346
347    /// Replaces the controlled column snapshot without rebuilding table state.
348    ///
349    /// A snapshot containing duplicate stable column IDs is ignored atomically.
350    pub fn set_columns(
351        &mut self,
352        columns: impl IntoIterator<Item = DiffColumn>,
353        window: &mut Window,
354        cx: &mut Context<Self>,
355    ) {
356        let columns = columns.into_iter().collect::<Vec<_>>();
357        // The injected columns carry authored widths: the upstream default
358        // (100px) truncated "Pending" into "Pending dec…".
359        let mut record_columns = vec![
360            RecordColumn::new(self.review_column_id.clone(), "Review")
361                .fixed(true)
362                .width_in_rems(6.5),
363            RecordColumn::new(self.decision_column_id.clone(), "Decision")
364                .fixed(true)
365                .width_in_rems(8.),
366        ];
367        record_columns.extend(columns.iter().cloned());
368        if !record_columns_have_unique_ids(&record_columns) {
369            return;
370        }
371        self.columns = columns.into();
372        if self.sort_column_id.as_ref().is_some_and(|sort_column_id| {
373            !self
374                .columns
375                .iter()
376                .any(|column| column.id() == sort_column_id.as_ref() && column.is_sortable())
377        }) {
378            self.sort_column_id = None;
379            self.sort_direction = None;
380        }
381        self.records_table.update(cx, |table, cx| {
382            table.set_columns(record_columns, window, cx);
383        });
384        cx.notify();
385    }
386
387    /// Replaces the controlled progressive proposal snapshot.
388    ///
389    /// Duplicate proposal IDs or duplicate cell column IDs make the complete
390    /// replacement invalid, so the prior controlled snapshot is retained.
391    pub fn set_rows(
392        &mut self,
393        rows: Progressive<Arc<[DiffRow]>>,
394        _window: &mut Window,
395        cx: &mut Context<Self>,
396    ) {
397        if !diff_rows_have_unique_ids(rows.content()) {
398            return;
399        }
400        self.rows = rows;
401        if self.selected_row_id.as_ref().is_some_and(|selected| {
402            !self
403                .rows
404                .content()
405                .iter()
406                .any(|row| row.id == *selected && !row.disabled)
407        }) {
408            self.selected_row_id = None;
409        }
410        self.projected_cells.store(0, Ordering::Relaxed);
411        let records = diff_record_skeletons(&self.rows);
412        let provider: Arc<dyn RecordCellProvider> = Arc::new(DiffCellProvider {
413            rows: self.rows.content().clone(),
414            decision_column_id: self.decision_column_id.clone(),
415            projected_cells: self.projected_cells.clone(),
416        });
417        self.records_table.update(cx, |table, cx| {
418            table.set_records_snapshot_with_cell_provider(records, Some(provider), cx);
419        });
420        cx.notify();
421    }
422
423    #[cfg(test)]
424    fn projected_cell_count(&self) -> usize {
425        self.projected_cells.load(Ordering::Relaxed)
426    }
427
428    /// Replaces the controlled selected proposal when the ID is enabled.
429    pub fn set_selected_row(
430        &mut self,
431        row_id: impl Into<SharedString>,
432        window: &mut Window,
433        cx: &mut Context<Self>,
434    ) {
435        let row_id = row_id.into();
436        if !self
437            .rows
438            .content()
439            .iter()
440            .any(|row| row.id == row_id && !row.disabled)
441        {
442            return;
443        }
444        self.selected_row_id = Some(row_id.clone());
445        self.records_table.update(cx, |table, cx| {
446            table.set_selected_row(row_id, window, cx);
447        });
448        cx.notify();
449    }
450
451    /// Clears the controlled selected proposal.
452    pub fn clear_selected_row(&mut self, cx: &mut Context<Self>) {
453        self.selected_row_id = None;
454        self.records_table
455            .update(cx, |table, cx| table.clear_selected_row(cx));
456        cx.notify();
457    }
458
459    /// Returns the controlled selected proposal ID.
460    pub fn selected_row_id(&self) -> Option<&str> {
461        self.selected_row_id.as_deref()
462    }
463
464    /// Replaces the controlled sort snapshot.
465    pub fn set_sort(
466        &mut self,
467        column_id: impl Into<SharedString>,
468        direction: Option<DiffSortDirection>,
469        window: &mut Window,
470        cx: &mut Context<Self>,
471    ) {
472        let column_id = column_id.into();
473        if direction.is_some()
474            && !self
475                .columns
476                .iter()
477                .any(|column| column.id() == column_id.as_ref() && column.is_sortable())
478        {
479            return;
480        }
481        self.sort_column_id = direction.map(|_| column_id.clone());
482        self.sort_direction = direction;
483        self.records_table.update(cx, |table, cx| {
484            table.set_sort(column_id, direction, window, cx);
485        });
486        cx.notify();
487    }
488
489    /// Returns the controlled sort column ID and direction.
490    pub fn sort(&self) -> Option<(&str, DiffSortDirection)> {
491        self.sort_column_id.as_deref().zip(self.sort_direction)
492    }
493
494    /// Scrolls the identified proposal into view when it exists.
495    pub fn scroll_to_row(&mut self, row_id: &str, cx: &mut Context<Self>) {
496        self.records_table
497            .update(cx, |table, cx| table.scroll_to_row(row_id, cx));
498    }
499
500    /// Scrolls the identified column into view when it exists.
501    pub fn scroll_to_column(&mut self, column_id: &str, cx: &mut Context<Self>) {
502        self.records_table
503            .update(cx, |table, cx| table.scroll_to_column(column_id, cx));
504    }
505
506    /// Moves keyboard focus to the virtualized diff grid.
507    pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
508        self.records_table.focus_handle(cx).focus(window, cx);
509    }
510
511    fn handle_records_event(&mut self, event: &RecordsTableEvent, cx: &mut Context<Self>) {
512        match event {
513            RecordsTableEvent::SelectionRequested { row_id, .. } => {
514                cx.emit(DiffTableEvent::SelectionRequested {
515                    id: self.id.clone(),
516                    row_id: row_id.clone(),
517                });
518            }
519            RecordsTableEvent::ActivationRequested { row_id, .. } => {
520                cx.emit(DiffTableEvent::ReviewRequested {
521                    id: self.id.clone(),
522                    row_id: row_id.clone(),
523                });
524            }
525            RecordsTableEvent::SortRequested {
526                column_id,
527                direction,
528                ..
529            } => {
530                cx.emit(DiffTableEvent::SortRequested {
531                    id: self.id.clone(),
532                    column_id: column_id.clone(),
533                    direction: *direction,
534                });
535            }
536        }
537    }
538
539    fn request_decision(
540        &mut self,
541        row_id: SharedString,
542        action: DiffProposalAction,
543        cx: &mut Context<Self>,
544    ) {
545        if self
546            .rows
547            .content()
548            .iter()
549            .any(|row| row.id == row_id && !row.disabled)
550        {
551            cx.emit(DiffTableEvent::DecisionRequested {
552                id: self.id.clone(),
553                row_id,
554                action,
555            });
556        }
557    }
558}
559
560impl EventEmitter<DiffTableEvent> for DiffTable {}
561
562impl Focusable for DiffTable {
563    fn focus_handle(&self, cx: &App) -> FocusHandle {
564        self.records_table.focus_handle(cx)
565    }
566}
567
568impl gpui::Styled for DiffTable {
569    fn style(&mut self) -> &mut gpui::StyleRefinement {
570        &mut self.style
571    }
572}
573
574impl Render for DiffTable {
575    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
576        let selected = self.selected_row_id.as_ref().and_then(|selected| {
577            self.rows
578                .content()
579                .iter()
580                .find(|row| row.id == *selected)
581                .cloned()
582        });
583        let tokens = cx.theme().semantic_tokens();
584
585        div()
586            .id(scoped_diff_id("root", &self.id, "surface"))
587            .debug_selector({
588                let id = self.id.clone();
589                move || scoped_diff_id("root", &id, "surface").to_string()
590            })
591            .size_full()
592            .min_h_0()
593            .flex()
594            .flex_col()
595            .gap(tokens.spacing.xs)
596            .role(Role::Group)
597            .aria_label(self.label.clone())
598            .child(div().flex_1().min_h_0().child(self.records_table.clone()))
599            .when_some(selected, |this, row| {
600                let row_id = row.id.clone();
601                let accept_owner = cx.weak_entity();
602                let reject_owner = accept_owner.clone();
603                let state = proposal_state_label(row.proposal_state);
604                let actions_label = format!("Proposal actions for {}. {state}", row.label);
605                this.child(
606                    div()
607                        .id(scoped_diff_id("actions", &self.id, &row.id))
608                        .flex_none()
609                        .flex()
610                        .items_center()
611                        .justify_between()
612                        .gap(tokens.spacing.sm)
613                        .role(Role::Group)
614                        .aria_label(actions_label.clone())
615                        .child(
616                            TextView::markdown(
617                                scoped_diff_id("decision", &self.id, &row.id),
618                                escape_markdown_text(&actions_label),
619                            )
620                            .selectable(true),
621                        )
622                        .child(
623                            div()
624                                .flex()
625                                .items_center()
626                                .gap(tokens.spacing.xs)
627                                .child(
628                                    proposal_action_control(
629                                        &self.id,
630                                        &row,
631                                        DiffProposalAction::Accept,
632                                        window,
633                                        cx,
634                                    )
635                                    .on_click({
636                                        let row_id = row_id.clone();
637                                        move |_, _, cx| {
638                                            let _ = accept_owner.update(cx, |table, cx| {
639                                                table.request_decision(
640                                                    row_id.clone(),
641                                                    DiffProposalAction::Accept,
642                                                    cx,
643                                                );
644                                            });
645                                        }
646                                    }),
647                                )
648                                .child(
649                                    proposal_action_control(
650                                        &self.id,
651                                        &row,
652                                        DiffProposalAction::Reject,
653                                        window,
654                                        cx,
655                                    )
656                                    .on_click(
657                                        move |_, _, cx| {
658                                            let _ = reject_owner.update(cx, |table, cx| {
659                                                table.request_decision(
660                                                    row_id.clone(),
661                                                    DiffProposalAction::Reject,
662                                                    cx,
663                                                );
664                                            });
665                                        },
666                                    ),
667                                ),
668                        ),
669                )
670            })
671            .refine_style(&self.style)
672    }
673}
674
675struct DiffCellProvider {
676    rows: Arc<[DiffRow]>,
677    decision_column_id: SharedString,
678    projected_cells: Arc<AtomicUsize>,
679}
680
681impl RecordCellProvider for DiffCellProvider {
682    fn cell(&self, row_ix: usize, column_id: &str) -> Option<RecordCell> {
683        let row = self.rows.get(row_ix)?;
684        let cell = if column_id == self.decision_column_id.as_ref() {
685            Some(RecordCell::status(
686                self.decision_column_id.clone(),
687                proposal_state_label(row.proposal_state),
688                proposal_state_tone(row.proposal_state),
689            ))
690        } else {
691            row.cell(column_id).map(diff_record_cell)
692        };
693        if cell.is_some() {
694            self.projected_cells.fetch_add(1, Ordering::Relaxed);
695        }
696        cell
697    }
698}
699
700fn diff_record_skeletons(rows: &Progressive<Arc<[DiffRow]>>) -> Progressive<Arc<[RecordRow]>> {
701    let records: Arc<[RecordRow]> = rows
702        .content()
703        .iter()
704        .map(diff_record_skeleton)
705        .collect::<Vec<_>>()
706        .into();
707    match rows.state() {
708        ProgressState::Pending => Progressive::pending(records),
709        ProgressState::Running => Progressive::running(records),
710        ProgressState::Complete => Progressive::complete(records),
711        ProgressState::Failed(reason) => Progressive::failed(records, reason.clone()),
712    }
713}
714
715fn diff_record_skeleton(row: &DiffRow) -> RecordRow {
716    let semantic_label = format!(
717        "{}; {}; {}",
718        row.label,
719        change_kind_label(row.change_kind),
720        proposal_state_label(row.proposal_state)
721    );
722    RecordRow::new(row.id.clone(), semantic_label).disabled(row.disabled)
723}
724
725#[cfg(test)]
726fn diff_record_row(row: &DiffRow, decision_column_id: &SharedString) -> RecordRow {
727    let cells = row
728        .cells
729        .iter()
730        .map(diff_record_cell)
731        .chain([RecordCell::status(
732            decision_column_id.clone(),
733            proposal_state_label(row.proposal_state),
734            proposal_state_tone(row.proposal_state),
735        )]);
736    diff_record_skeleton(row).cells(cells)
737}
738
739fn diff_record_cell(cell: &DiffCell) -> RecordCell {
740    // Change semantics travel as data — the records renderer draws the
741    // tone glyph, the struck previous value, and the new one — and an
742    // unchanged cell is simply its value: the prose prefixes that wrapped
743    // and clipped ("Changed: Classic → Seasonal") are gone, and three
744    // quiet rows no longer shout "Unchanged" four times each.
745    match cell.change_kind {
746        DiffChangeKind::Added => RecordCell::change(
747            cell.column_id.clone(),
748            None,
749            cell.after.clone(),
750            RecordStatusTone::Positive,
751        ),
752        DiffChangeKind::Removed => RecordCell::change(
753            cell.column_id.clone(),
754            cell.before.clone(),
755            None,
756            RecordStatusTone::Critical,
757        ),
758        DiffChangeKind::Changed => RecordCell::change(
759            cell.column_id.clone(),
760            cell.before.clone(),
761            cell.after.clone(),
762            RecordStatusTone::Caution,
763        ),
764        DiffChangeKind::Unchanged => RecordCell::new(
765            cell.column_id.clone(),
766            cell.after.clone().unwrap_or_default(),
767        ),
768    }
769}
770
771fn change_kind_label(kind: DiffChangeKind) -> &'static str {
772    match kind {
773        DiffChangeKind::Added => "Added",
774        DiffChangeKind::Removed => "Removed",
775        DiffChangeKind::Changed => "Changed",
776        DiffChangeKind::Unchanged => "Unchanged",
777    }
778}
779
780fn proposal_state_label(state: DiffProposalState) -> &'static str {
781    match state {
782        DiffProposalState::Pending => "Pending",
783        DiffProposalState::Accepted => "Accepted",
784        DiffProposalState::Rejected => "Rejected",
785    }
786}
787
788fn proposal_state_tone(state: DiffProposalState) -> RecordStatusTone {
789    match state {
790        DiffProposalState::Pending => RecordStatusTone::Caution,
791        DiffProposalState::Accepted => RecordStatusTone::Positive,
792        DiffProposalState::Rejected => RecordStatusTone::Critical,
793    }
794}
795
796fn proposal_action_control(
797    table_id: &str,
798    row: &DiffRow,
799    action: DiffProposalAction,
800    window: &mut Window,
801    cx: &mut App,
802) -> gpui_base::Button {
803    let (kind, verb, decided_label, decided) = match action {
804        DiffProposalAction::Accept => (
805            "accept",
806            "Accept",
807            "accepted",
808            row.proposal_state == DiffProposalState::Accepted,
809        ),
810        DiffProposalAction::Reject => (
811            "reject",
812            "Reject",
813            "rejected",
814            row.proposal_state == DiffProposalState::Rejected,
815        ),
816    };
817    let disabled = row.disabled || decided;
818    let accessibility_label = if row.disabled {
819        format!("Unavailable: {verb} {}", row.label)
820    } else if decided {
821        format!("Already {decided_label}: {}", row.label)
822    } else {
823        format!("{verb} {}", row.label)
824    };
825    let debug_id = scoped_diff_id(kind, table_id, &row.id);
826    outlined_control_with_label(debug_id.clone(), accessibility_label, verb, window, cx)
827        .debug_selector(move || debug_id.to_string())
828        .disabled(disabled)
829}
830
831fn scoped_diff_id(kind: &str, table_id: &str, item_id: &str) -> SharedString {
832    format!("diff-table-{kind}-{}:{table_id}{item_id}", table_id.len()).into()
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838    use gpui::{
839        Element as _, IntoElement as _, RenderOnce as _, TestAppContext, VisualTestContext,
840        accesskit, canvas, px, size,
841    };
842    use std::sync::{Arc, Mutex};
843
844    #[test]
845    fn diff_projection_keeps_change_state_in_readable_cell_values() {
846        // Change semantics are data, not prose: the accessible value reads
847        // as a sentence, the tone carries the kind, and an unchanged cell
848        // is simply its value with no tone at all.
849        for (cell, accessible, tone) in [
850            (
851                DiffCell::added("value", "Pistachio"),
852                "added Pistachio",
853                Some(RecordStatusTone::Positive),
854            ),
855            (
856                DiffCell::removed("value", "Bubblegum"),
857                "removed Bubblegum",
858                Some(RecordStatusTone::Critical),
859            ),
860            (
861                DiffCell::changed("value", "Mint Chip", "Pistachio"),
862                "was Mint Chip, now Pistachio",
863                Some(RecordStatusTone::Caution),
864            ),
865            (DiffCell::unchanged("value", "Classic"), "Classic", None),
866        ] {
867            let projected = diff_record_cell(&cell);
868            assert_eq!(projected.accessible_value().as_ref(), accessible);
869            assert_eq!(projected.status_tone(), tone);
870        }
871
872        let decision_column_id: SharedString = "decision".into();
873        for (state, expected, tone) in [
874            (
875                DiffProposalState::Pending,
876                "Pending",
877                RecordStatusTone::Caution,
878            ),
879            (
880                DiffProposalState::Accepted,
881                "Accepted",
882                RecordStatusTone::Positive,
883            ),
884            (
885                DiffProposalState::Rejected,
886                "Rejected",
887                RecordStatusTone::Critical,
888            ),
889        ] {
890            let row = DiffRow::new("proposal", "Proposal", DiffChangeKind::Changed).state(state);
891            let projected = diff_record_row(&row, &decision_column_id);
892            let decision = projected
893                .cell("decision")
894                .expect("every projected row should expose a visible decision cell");
895            assert_eq!(decision.value(), expected);
896            assert_eq!(decision.status_tone(), Some(tone));
897        }
898    }
899
900    #[gpui::test]
901    fn thousand_row_diff_projects_cells_only_for_the_virtual_viewport(cx: &mut TestAppContext) {
902        cx.update(crate::init);
903        let (table, cx) =
904            cx.add_window_view(|window, cx| DiffTable::new("lazy", "Lazy diff", window, cx));
905        let cx: &mut VisualTestContext = cx;
906        cx.simulate_resize(size(px(640.), px(300.)));
907        let columns = (0..8)
908            .map(|index| DiffColumn::new(format!("column-{index}"), format!("Column {index}")))
909            .collect::<Vec<_>>();
910        let rows = (0..1_000)
911            .map(|row| {
912                DiffRow::new(
913                    format!("row-{row}"),
914                    format!("Row {row}"),
915                    DiffChangeKind::Changed,
916                )
917                .cells((0..8).map(|column| {
918                    DiffCell::changed(
919                        format!("column-{column}"),
920                        format!("Before {row}:{column}"),
921                        format!("After {row}:{column}"),
922                    )
923                }))
924            })
925            .collect::<Vec<_>>();
926
927        cx.update(|window, cx| {
928            table.update(cx, |table, cx| {
929                table.set_columns(columns, window, cx);
930                table.set_rows(Progressive::complete(rows.into()), window, cx);
931                assert_eq!(table.projected_cell_count(), 0);
932            });
933            window.draw(cx).clear(cx);
934        });
935        let projected = table.read_with(cx, |table, _| table.projected_cell_count());
936        assert!(
937            projected > 0,
938            "visible diff cells should be projected on demand"
939        );
940        assert!(
941            projected < 256,
942            "offscreen diff cells must not be projected eagerly; got {projected}"
943        );
944    }
945
946    #[gpui::test]
947    fn deciding_a_proposal_acknowledges_and_a_loaded_diff_is_settled(cx: &mut TestAppContext) {
948        cx.update(crate::init);
949        let (table, cx) =
950            cx.add_window_view(|window, cx| DiffTable::new("decide", "Decide diff", window, cx));
951        let cx: &mut VisualTestContext = cx;
952        cx.simulate_resize(size(px(640.), px(300.)));
953        let rows = |state: DiffProposalState| {
954            vec![
955                DiffRow::new("keep", "Keep", DiffChangeKind::Changed)
956                    .cells([DiffCell::changed("value", "Before", "After")]),
957                DiffRow::new("decide", "Decide", DiffChangeKind::Changed)
958                    .state(state)
959                    .cells([DiffCell::changed("value", "Old", "New")]),
960            ]
961        };
962        cx.update(|window, cx| {
963            table.update(cx, |table, cx| {
964                table.set_columns([DiffColumn::new("value", "Value")], window, cx);
965                table.set_rows(
966                    Progressive::complete(rows(DiffProposalState::Pending).into()),
967                    window,
968                    cx,
969                );
970            });
971            window.draw(cx).clear(cx);
972        });
973        cx.executor()
974            .advance_clock(std::time::Duration::from_secs(2));
975        cx.update(|window, cx| window.draw(cx).clear(cx));
976        crate::motion::take_reveal_frame_requests();
977        cx.update(|window, cx| window.draw(cx).clear(cx));
978        assert_eq!(
979            crate::motion::take_reveal_frame_requests(),
980            0,
981            "a loaded diff presents its decisions settled"
982        );
983
984        cx.update(|window, cx| {
985            table.update(cx, |table, cx| {
986                table.set_rows(
987                    Progressive::complete(rows(DiffProposalState::Accepted).into()),
988                    window,
989                    cx,
990                );
991            });
992            window.draw(cx).clear(cx);
993        });
994        assert!(
995            crate::motion::take_reveal_frame_requests() > 0,
996            "an accepted proposal must acknowledge its new decision"
997        );
998    }
999
1000    type CapturedControls = Arc<Mutex<Vec<(Option<Role>, accesskit::Node)>>>;
1001
1002    struct DiffControlProbe {
1003        captured: CapturedControls,
1004    }
1005
1006    impl Render for DiffControlProbe {
1007        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl gpui::IntoElement {
1008            let captured = self.captured.clone();
1009            canvas(
1010                move |_, window, cx| {
1011                    let pending = DiffRow::new("pending", "Pistachio", DiffChangeKind::Changed);
1012                    let accepted = DiffRow::new("accepted", "Rocky Road", DiffChangeKind::Removed)
1013                        .state(DiffProposalState::Accepted);
1014                    let rejected = DiffRow::new("rejected", "Bubblegum", DiffChangeKind::Removed)
1015                        .state(DiffProposalState::Rejected);
1016                    let controls = [
1017                        proposal_action_control(
1018                            "cleanup",
1019                            &pending,
1020                            DiffProposalAction::Accept,
1021                            window,
1022                            cx,
1023                        )
1024                        .on_click(|_, _, _| {}),
1025                        proposal_action_control(
1026                            "cleanup",
1027                            &pending,
1028                            DiffProposalAction::Reject,
1029                            window,
1030                            cx,
1031                        )
1032                        .on_click(|_, _, _| {}),
1033                        proposal_action_control(
1034                            "cleanup",
1035                            &accepted,
1036                            DiffProposalAction::Accept,
1037                            window,
1038                            cx,
1039                        )
1040                        .on_click(|_, _, _| {}),
1041                        proposal_action_control(
1042                            "cleanup",
1043                            &rejected,
1044                            DiffProposalAction::Reject,
1045                            window,
1046                            cx,
1047                        )
1048                        .on_click(|_, _, _| {}),
1049                    ];
1050                    let mut result = Vec::new();
1051                    for control in controls {
1052                        let element = control.render(window, cx).into_element();
1053                        let role = element.a11y_role();
1054                        let mut node = accesskit::Node::new(Role::Unknown);
1055                        element.write_a11y_info(&mut node);
1056                        result.push((role, node));
1057                    }
1058                    *captured.lock().expect("capture mutex should be available") = result;
1059                },
1060                |_, _, _, _| {},
1061            )
1062        }
1063    }
1064
1065    #[gpui::test]
1066    fn proposal_controls_expose_names_roles_and_available_actions(cx: &mut TestAppContext) {
1067        cx.update(crate::init);
1068        let captured = Arc::new(Mutex::new(Vec::new()));
1069        let result = captured.clone();
1070        let (_, cx) = cx.add_window_view(move |_, _| DiffControlProbe { captured });
1071        cx.update(|window, cx| window.draw(cx).clear(cx));
1072
1073        let controls = result.lock().expect("capture mutex should be available");
1074        assert_eq!(controls.len(), 4);
1075        assert_eq!(controls[0].0, Some(Role::Button));
1076        assert_eq!(controls[0].1.label(), Some("Accept Pistachio"));
1077        assert!(controls[0].1.supports_action(accesskit::Action::Click));
1078        assert_eq!(controls[1].0, Some(Role::Button));
1079        assert_eq!(controls[1].1.label(), Some("Reject Pistachio"));
1080        assert!(controls[1].1.supports_action(accesskit::Action::Click));
1081        assert_eq!(controls[2].0, Some(Role::Button));
1082        assert_eq!(controls[2].1.label(), Some("Already accepted: Rocky Road"));
1083        assert!(!controls[2].1.supports_action(accesskit::Action::Click));
1084        assert_eq!(controls[3].0, Some(Role::Button));
1085        assert_eq!(controls[3].1.label(), Some("Already rejected: Bubblegum"));
1086        assert!(!controls[3].1.supports_action(accesskit::Action::Click));
1087    }
1088}