1use 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
29pub type DiffColumn = RecordColumn;
31
32pub type DiffColumnAlignment = RecordColumnAlignment;
34
35pub type DiffSortDirection = RecordSortDirection;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DiffChangeKind {
41 Added,
43 Removed,
45 Changed,
47 Unchanged,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum DiffProposalState {
54 Pending,
56 Accepted,
58 Rejected,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum DiffProposalAction {
65 Accept,
67 Reject,
69}
70
71#[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 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 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 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 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 pub fn column_id(&self) -> &str {
128 self.column_id.as_ref()
129 }
130
131 pub fn before(&self) -> Option<&str> {
133 self.before.as_deref()
134 }
135
136 pub fn after(&self) -> Option<&str> {
138 self.after.as_deref()
139 }
140
141 pub fn change_kind(&self) -> DiffChangeKind {
143 self.change_kind
144 }
145}
146
147#[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 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 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 pub fn state(mut self, proposal_state: DiffProposalState) -> Self {
183 self.proposal_state = proposal_state;
184 self
185 }
186
187 pub fn disabled(mut self, disabled: bool) -> Self {
189 self.disabled = disabled;
190 self
191 }
192
193 pub fn id(&self) -> &str {
195 self.id.as_ref()
196 }
197
198 pub fn label(&self) -> &str {
200 self.label.as_ref()
201 }
202
203 pub fn change_kind(&self) -> DiffChangeKind {
205 self.change_kind
206 }
207
208 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 pub fn all_cells(&self) -> &[DiffCell] {
217 &self.cells
218 }
219
220 pub fn proposal_state(&self) -> DiffProposalState {
222 self.proposal_state
223 }
224
225 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#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum DiffTableEvent {
246 SelectionRequested {
248 id: SharedString,
250 row_id: SharedString,
252 },
253 ReviewRequested {
255 id: SharedString,
257 row_id: SharedString,
259 },
260 SortRequested {
262 id: SharedString,
264 column_id: SharedString,
266 direction: Option<DiffSortDirection>,
268 },
269 DecisionRequested {
271 id: SharedString,
273 row_id: SharedString,
275 action: DiffProposalAction,
277 },
278}
279
280pub struct DiffTable {
286 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 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 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 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 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 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 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 pub fn selected_row_id(&self) -> Option<&str> {
461 self.selected_row_id.as_deref()
462 }
463
464 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 pub fn sort(&self) -> Option<(&str, DiffSortDirection)> {
491 self.sort_column_id.as_deref().zip(self.sort_direction)
492 }
493
494 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 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 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 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 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}