1use std::collections::HashMap;
25use std::rc::Rc;
26
27use gpui::{
28 AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
29 Styled, Window, div, prelude::FluentBuilder, px,
30};
31use gpui_kit_semantics::{NodeSpec, Role, Semantic};
32use gpui_kit_theme::{
33 ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, Theme, TypeScale,
34};
35
36use crate::content::code_view::code_runs;
37use crate::content::markdown::CodeSpan;
38use crate::data::{List, ListItem};
39use crate::display::badge::Tone;
40use crate::display::empty::{EmptyKind, EmptyState};
41use crate::foundation::{Ident, StyledExt};
42use crate::strings::{ActiveStrings, StringKey};
43
44type EventHandler = Rc<dyn Fn(DiffViewEvent, &mut Window, &mut App)>;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum DiffPresentation {
49 #[default]
50 Unified,
51 Split,
52}
53
54impl DiffPresentation {
55 pub fn name(self) -> &'static str {
56 match self {
57 Self::Unified => "unified",
58 Self::Split => "split",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65enum DiffLineMark {
66 #[default]
67 Context,
68 Added,
69 Removed,
70}
71
72impl DiffLineMark {
73 fn tone(self) -> Tone {
74 match self {
75 Self::Context => Tone::Neutral,
76 Self::Added => Tone::Success,
77 Self::Removed => Tone::Danger,
78 }
79 }
80
81 fn key(self) -> StringKey {
82 match self {
83 Self::Context => StringKey::DiffContextLine,
84 Self::Added => StringKey::CodeLineAdded,
85 Self::Removed => StringKey::CodeLineRemoved,
86 }
87 }
88
89 fn prefix(self) -> &'static str {
90 match self {
91 Self::Context => " ",
92 Self::Added => "+",
93 Self::Removed => "-",
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100struct DiffSide {
101 number: Option<usize>,
102 text: SharedString,
103 spans: Vec<CodeSpan>,
104 mark: DiffLineMark,
105}
106
107impl DiffSide {
108 fn new(text: impl Into<SharedString>, mark: DiffLineMark) -> Self {
109 Self {
110 number: None,
111 text: text.into(),
112 spans: Vec::new(),
113 mark,
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct DiffLine {
126 pub id: SharedString,
127 old: Option<DiffSide>,
128 new: Option<DiffSide>,
129}
130
131impl DiffLine {
132 pub fn new(id: impl Into<SharedString>, text: impl Into<SharedString>) -> Self {
135 let side = DiffSide::new(text, DiffLineMark::Context);
136 Self {
137 id: id.into(),
138 old: Some(side.clone()),
139 new: Some(side),
140 }
141 }
142
143 pub fn added(id: impl Into<SharedString>, text: impl Into<SharedString>) -> Self {
145 Self {
146 id: id.into(),
147 old: None,
148 new: Some(DiffSide::new(text, DiffLineMark::Added)),
149 }
150 }
151
152 pub fn removed(id: impl Into<SharedString>, text: impl Into<SharedString>) -> Self {
154 Self {
155 id: id.into(),
156 old: Some(DiffSide::new(text, DiffLineMark::Removed)),
157 new: None,
158 }
159 }
160
161 pub fn paired(
165 id: impl Into<SharedString>,
166 old: impl Into<SharedString>,
167 new: impl Into<SharedString>,
168 ) -> Self {
169 Self {
170 id: id.into(),
171 old: Some(DiffSide::new(old, DiffLineMark::Removed)),
172 new: Some(DiffSide::new(new, DiffLineMark::Added)),
173 }
174 }
175
176 pub fn old_number(mut self, number: usize) -> Self {
177 if let Some(old) = &mut self.old {
178 old.number = Some(number);
179 }
180 self
181 }
182
183 pub fn new_number(mut self, number: usize) -> Self {
184 if let Some(new) = &mut self.new {
185 new.number = Some(number);
186 }
187 self
188 }
189
190 pub fn spans(mut self, spans: impl IntoIterator<Item = CodeSpan>) -> Self {
193 let spans: Vec<CodeSpan> = spans.into_iter().collect();
194 if let Some(old) = &mut self.old {
195 old.spans = spans.clone();
196 }
197 if let Some(new) = &mut self.new {
198 new.spans = spans;
199 }
200 self
201 }
202
203 pub fn old_spans(mut self, spans: impl IntoIterator<Item = CodeSpan>) -> Self {
204 let spans: Vec<CodeSpan> = spans.into_iter().collect();
205 if self.is_context() {
206 if let Some(old) = &mut self.old {
207 old.spans = spans.clone();
208 }
209 if let Some(new) = &mut self.new {
210 new.spans = spans;
211 }
212 } else if let Some(old) = &mut self.old {
213 old.spans = spans;
214 }
215 self
216 }
217
218 pub fn new_spans(mut self, spans: impl IntoIterator<Item = CodeSpan>) -> Self {
219 let spans: Vec<CodeSpan> = spans.into_iter().collect();
220 if self.is_context() {
221 if let Some(old) = &mut self.old {
222 old.spans = spans.clone();
223 }
224 if let Some(new) = &mut self.new {
225 new.spans = spans;
226 }
227 } else if let Some(new) = &mut self.new {
228 new.spans = spans;
229 }
230 self
231 }
232
233 fn is_context(&self) -> bool {
234 matches!(
235 (&self.old, &self.new),
236 (Some(old), Some(new))
237 if old.mark == DiffLineMark::Context && new.mark == DiffLineMark::Context
238 )
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct DiffHunk {
245 pub id: SharedString,
246 pub header: SharedString,
247 pub lines: Vec<DiffLine>,
248}
249
250impl DiffHunk {
251 pub fn new(
252 id: impl Into<SharedString>,
253 header: impl Into<SharedString>,
254 lines: impl IntoIterator<Item = DiffLine>,
255 ) -> Self {
256 Self {
257 id: id.into(),
258 header: header.into(),
259 lines: lines.into_iter().collect(),
260 }
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct DiffFile {
267 pub id: SharedString,
268 pub label: SharedString,
269 pub hunks: Vec<DiffHunk>,
270}
271
272impl DiffFile {
273 pub fn new(
274 id: impl Into<SharedString>,
275 label: impl Into<SharedString>,
276 hunks: impl IntoIterator<Item = DiffHunk>,
277 ) -> Self {
278 Self {
279 id: id.into(),
280 label: label.into(),
281 hunks: hunks.into_iter().collect(),
282 }
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
288pub enum DiffViewEvent {
289 FileActivated {
290 file_id: SharedString,
291 },
292 HunkActivated {
293 file_id: SharedString,
294 hunk_id: SharedString,
295 },
296 LineActivated {
297 file_id: SharedString,
298 hunk_id: SharedString,
299 line_id: SharedString,
300 },
301}
302
303#[derive(IntoElement)]
305pub struct DiffView {
306 ident: Ident,
307 files: Vec<DiffFile>,
308 presentation: DiffPresentation,
309 visible_rows: usize,
310 on_event: Option<EventHandler>,
311}
312
313impl std::fmt::Debug for DiffView {
314 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 formatter
316 .debug_struct("DiffView")
317 .field("ident", &self.ident)
318 .field("files", &self.files.len())
319 .field("presentation", &self.presentation)
320 .field("visible_rows", &self.visible_rows)
321 .field("has_handler", &self.on_event.is_some())
322 .finish()
323 }
324}
325
326impl DiffView {
327 pub fn new(ident: impl Into<Ident>, files: impl IntoIterator<Item = DiffFile>) -> Self {
328 Self {
329 ident: ident.into(),
330 files: files.into_iter().collect(),
331 presentation: DiffPresentation::Unified,
332 visible_rows: 18,
333 on_event: None,
334 }
335 }
336
337 pub fn presentation(mut self, presentation: DiffPresentation) -> Self {
338 self.presentation = presentation;
339 self
340 }
341
342 pub fn visible_rows(mut self, rows: usize) -> Self {
345 self.visible_rows = rows.max(1);
346 self
347 }
348
349 pub fn on_event(
352 mut self,
353 handler: impl Fn(DiffViewEvent, &mut Window, &mut App) + 'static,
354 ) -> Self {
355 self.on_event = Some(Rc::new(handler));
356 self
357 }
358}
359
360#[derive(Debug, Clone)]
361struct FlatRow {
362 id: SharedString,
363 event: DiffViewEvent,
364 kind: FlatKind,
365}
366
367#[derive(Debug, Clone)]
368enum FlatKind {
369 File(SharedString),
370 Hunk(SharedString),
371 Unified {
372 side: DiffSide,
373 old_number: Option<usize>,
374 new_number: Option<usize>,
375 },
376 Split {
377 old: Option<DiffSide>,
378 new: Option<DiffSide>,
379 },
380}
381
382impl FlatKind {
383 fn label_key(&self) -> StringKey {
384 match self {
385 Self::File(_) => StringKey::DiffFile,
386 Self::Hunk(_) => StringKey::DiffHunk,
387 Self::Unified { side, .. } => side.mark.key(),
388 Self::Split { old, new } => match (old, new) {
389 (Some(old), Some(new))
390 if old.mark == DiffLineMark::Context && new.mark == DiffLineMark::Context =>
391 {
392 StringKey::DiffContextLine
393 }
394 (Some(_), Some(_)) => StringKey::DiffChangedLine,
395 (Some(old), None) => old.mark.key(),
396 (None, Some(new)) => new.mark.key(),
397 (None, None) => StringKey::DiffContextLine,
398 },
399 }
400 }
401}
402
403impl RenderOnce for DiffView {
404 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
405 let theme = cx.theme().clone();
406 let rows = Rc::new(flatten(self.files, self.presentation));
407 let count = rows.len();
408 let list_ident = self.ident.child("rows");
409
410 let body: AnyElement = if rows.is_empty() {
411 EmptyState::new(
412 self.ident.child("empty"),
413 cx.strings().text(StringKey::DiffEmpty),
414 )
415 .kind(EmptyKind::Empty)
416 .into_any_element()
417 } else {
418 let rendered = Rc::clone(&rows);
419 let row_theme = theme.clone();
420 let mut list = List::new(list_ident.clone(), count, move |index, _, cx| {
421 let row = &rendered[index];
422 let label = cx.strings().text(row.kind.label_key());
423 ListItem::new(row.id.clone(), diff_row(&row.kind, &row_theme))
424 .text(label)
427 })
428 .row_height(theme.control.get(ControlSize::Sm).height)
429 .visible_rows(self.visible_rows);
430
431 if let Some(handler) = self.on_event.clone() {
432 let indices: Rc<HashMap<SharedString, usize>> = Rc::new(
433 rows.iter()
434 .enumerate()
435 .map(|(index, row)| (row.id.clone(), index))
436 .collect(),
437 );
438 let event_rows = Rc::clone(&rows);
439 list = list.on_select(move |id, window, cx| {
440 if let Some(index) = indices.get(&id) {
441 handler(event_rows[*index].event.clone(), window, cx);
442 }
443 });
444 }
445 list.into_any_element()
446 };
447
448 div()
449 .id(self.ident.element_id())
450 .column()
451 .w_full()
452 .p_token(&theme, Space::Sm)
453 .radius(&theme, Radius::Card)
454 .frame(&theme, Surface::Raised, Elevation::Raised)
455 .child(body)
456 .semantic_in(
457 cx,
458 NodeSpec::new(self.ident.semantic_id(), Role::Region)
459 .value(self.presentation.name())
460 .read_only(true),
461 )
462 }
463}
464
465fn flatten(files: Vec<DiffFile>, presentation: DiffPresentation) -> Vec<FlatRow> {
466 let mut rows = Vec::new();
467 for file in files {
468 let file_path = Ident::new("file").child(file.id.as_ref());
469 rows.push(FlatRow {
470 id: file_path.semantic_id(),
471 event: DiffViewEvent::FileActivated {
472 file_id: file.id.clone(),
473 },
474 kind: FlatKind::File(file.label),
475 });
476 for hunk in file.hunks {
477 let hunk_path = file_path.child("hunk").child(hunk.id.as_ref());
478 rows.push(FlatRow {
479 id: hunk_path.semantic_id(),
480 event: DiffViewEvent::HunkActivated {
481 file_id: file.id.clone(),
482 hunk_id: hunk.id.clone(),
483 },
484 kind: FlatKind::Hunk(hunk.header),
485 });
486 for line in hunk.lines {
487 let line_path = hunk_path.child("line").child(line.id.as_ref());
488 let event = DiffViewEvent::LineActivated {
489 file_id: file.id.clone(),
490 hunk_id: hunk.id.clone(),
491 line_id: line.id.clone(),
492 };
493 match presentation {
494 DiffPresentation::Split => rows.push(FlatRow {
495 id: line_path.semantic_id(),
496 event,
497 kind: FlatKind::Split {
498 old: line.old,
499 new: line.new,
500 },
501 }),
502 DiffPresentation::Unified => match (line.old, line.new) {
503 (Some(old), Some(new))
504 if old.text == new.text
505 && old.spans == new.spans
506 && old.mark == DiffLineMark::Context
507 && new.mark == DiffLineMark::Context =>
508 {
509 rows.push(FlatRow {
510 id: line_path.semantic_id(),
511 event,
512 kind: FlatKind::Unified {
513 old_number: old.number,
514 new_number: new.number,
515 side: old,
516 },
517 });
518 }
519 (Some(old), Some(new)) => {
520 rows.push(FlatRow {
521 id: line_path.child("old").semantic_id(),
522 event: event.clone(),
523 kind: FlatKind::Unified {
524 old_number: old.number,
525 new_number: None,
526 side: old,
527 },
528 });
529 rows.push(FlatRow {
530 id: line_path.child("new").semantic_id(),
531 event,
532 kind: FlatKind::Unified {
533 old_number: None,
534 new_number: new.number,
535 side: new,
536 },
537 });
538 }
539 (Some(old), None) => rows.push(FlatRow {
540 id: line_path.semantic_id(),
541 event,
542 kind: FlatKind::Unified {
543 old_number: old.number,
544 new_number: None,
545 side: old,
546 },
547 }),
548 (None, Some(new)) => rows.push(FlatRow {
549 id: line_path.semantic_id(),
550 event,
551 kind: FlatKind::Unified {
552 old_number: None,
553 new_number: new.number,
554 side: new,
555 },
556 }),
557 (None, None) => {}
558 },
559 }
560 }
561 }
562 }
563 rows
564}
565
566fn diff_row(kind: &FlatKind, theme: &Theme) -> AnyElement {
567 match kind {
568 FlatKind::File(label) => div()
569 .row()
570 .items_center()
571 .w_full()
572 .h_full()
573 .px_token(theme, Space::Sm)
574 .type_scale(theme, TypeScale::Label)
575 .text_color(theme.colors.text)
576 .bg(theme.colors.raised)
577 .child(label.clone())
578 .into_any_element(),
579 FlatKind::Hunk(header) => div()
580 .row()
581 .items_center()
582 .w_full()
583 .h_full()
584 .px_token(theme, Space::Sm)
585 .font_family(theme.typography.mono.clone())
586 .text_size(px(theme.typography.code.size))
587 .text_color(theme.colors.accent)
588 .bg(theme
589 .colors
590 .accent
591 .opacity(theme.effects.selected_ring_alpha))
592 .child(header.clone())
593 .into_any_element(),
594 FlatKind::Unified {
595 side,
596 old_number,
597 new_number,
598 } => unified_line(side, *old_number, *new_number, theme),
599 FlatKind::Split { old, new } => split_line(old.as_ref(), new.as_ref(), theme),
600 }
601}
602
603fn unified_line(
604 side: &DiffSide,
605 old_number: Option<usize>,
606 new_number: Option<usize>,
607 theme: &Theme,
608) -> AnyElement {
609 let color = side.mark.tone().color(theme);
610 div()
611 .row()
612 .items_center()
613 .w_full()
614 .h_full()
615 .font_family(theme.typography.mono.clone())
616 .text_size(px(theme.typography.code.size))
617 .line_height(px(theme.typography.code.line_height))
618 .when(side.mark != DiffLineMark::Context, |element| {
619 element.bg(color.opacity(theme.effects.selected_ring_alpha))
620 })
621 .child(number(old_number, theme))
622 .child(number(new_number, theme))
623 .child(
624 div()
625 .flex_none()
626 .w(px(18.0))
627 .text_align(gpui::TextAlign::Center)
628 .text_color(color)
629 .child(side.mark.prefix()),
630 )
631 .child(
632 div()
633 .row()
634 .flex_1()
635 .min_w_0()
636 .overflow_hidden()
637 .whitespace_nowrap()
638 .children(code_runs(theme, side.text.as_ref(), &side.spans)),
639 )
640 .into_any_element()
641}
642
643fn split_line(old: Option<&DiffSide>, new: Option<&DiffSide>, theme: &Theme) -> AnyElement {
644 div()
645 .row()
646 .items_center()
647 .w_full()
648 .h_full()
649 .font_family(theme.typography.mono.clone())
650 .text_size(px(theme.typography.code.size))
651 .line_height(px(theme.typography.code.line_height))
652 .child(code_side(old, theme))
653 .child(
654 div()
655 .flex_none()
656 .w(px(theme.borders.hairline))
657 .h_full()
658 .bg(theme.colors.hairline_strong),
659 )
660 .child(code_side(new, theme))
661 .into_any_element()
662}
663
664fn code_side(side: Option<&DiffSide>, theme: &Theme) -> AnyElement {
665 let mark = side.map_or(DiffLineMark::Context, |side| side.mark);
666 let color = mark.tone().color(theme);
667 div()
668 .row()
669 .items_center()
670 .flex_1()
671 .min_w_0()
672 .h_full()
673 .when(side.is_some() && mark != DiffLineMark::Context, |element| {
674 element.bg(color.opacity(theme.effects.selected_ring_alpha))
675 })
676 .child(number(side.and_then(|side| side.number), theme))
677 .child(
678 div()
679 .flex_none()
680 .w(px(18.0))
681 .text_align(gpui::TextAlign::Center)
682 .text_color(color)
683 .child(side.map_or("", |side| side.mark.prefix())),
684 )
685 .child(
686 div()
687 .row()
688 .flex_1()
689 .min_w_0()
690 .overflow_hidden()
691 .whitespace_nowrap()
692 .children(
693 side.into_iter()
694 .flat_map(|side| code_runs(theme, side.text.as_ref(), &side.spans)),
695 ),
696 )
697 .into_any_element()
698}
699
700fn number(number: Option<usize>, theme: &Theme) -> AnyElement {
701 div()
702 .flex_none()
703 .w(px(44.0))
704 .pr(px(theme.space(Space::Xs)))
705 .overflow_hidden()
706 .text_align(gpui::TextAlign::Right)
707 .text_color(theme.colors.text_faint)
708 .child(number.map_or_else(SharedString::default, |number| {
709 SharedString::from(number.to_string())
710 }))
711 .into_any_element()
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 #[test]
719 fn flattening_uses_only_caller_identity() {
720 let files = vec![DiffFile::new(
721 "readme",
722 "README.md",
723 [DiffHunk::new(
724 "intro",
725 "@@ introduction @@",
726 [DiffLine::new("line-title", "# Title")],
727 )],
728 )];
729 let rows = flatten(files, DiffPresentation::Unified);
730 assert_eq!(rows[0].id, "file.readme");
731 assert_eq!(rows[1].id, "file.readme.hunk.intro");
732 assert_eq!(rows[2].id, "file.readme.hunk.intro.line.line-title");
733 }
734
735 #[test]
736 fn caller_aligned_replacement_is_one_split_row_and_two_unified_rows() {
737 let files = || {
738 vec![DiffFile::new(
739 "source",
740 "src/lib.rs",
741 [DiffHunk::new(
742 "change",
743 "@@ fixture @@",
744 [DiffLine::paired("cache", "old_cache", "verified_cache")],
745 )],
746 )]
747 };
748
749 let split = flatten(files(), DiffPresentation::Split);
750 assert_eq!(split.len(), 3);
751 assert_eq!(split[2].id, "file.source.hunk.change.line.cache");
752 match &split[2].kind {
753 FlatKind::Split { old, new } => {
754 assert_eq!(
755 old.as_ref().map(|side| side.text.as_ref()),
756 Some("old_cache")
757 );
758 assert_eq!(
759 new.as_ref().map(|side| side.text.as_ref()),
760 Some("verified_cache")
761 );
762 }
763 _ => panic!("replacement must remain aligned in split presentation"),
764 }
765
766 let unified = flatten(files(), DiffPresentation::Unified);
767 assert_eq!(unified.len(), 4);
768 assert_eq!(unified[2].id, "file.source.hunk.change.line.cache.old");
769 assert_eq!(unified[3].id, "file.source.hunk.change.line.cache.new");
770 }
771
772 #[test]
773 fn every_public_line_shape_has_the_same_presence_in_both_presentations() {
774 let lines = [
775 DiffLine::new("context", "same"),
776 DiffLine::added("added", "new"),
777 DiffLine::removed("removed", "old"),
778 DiffLine::paired("paired", "before", "after"),
779 ];
780 let files = || {
781 vec![DiffFile::new(
782 "source",
783 "src/lib.rs",
784 [DiffHunk::new("change", "@@ fixture @@", lines.clone())],
785 )]
786 };
787
788 let split = flatten(files(), DiffPresentation::Split);
789 let unified = flatten(files(), DiffPresentation::Unified);
790
791 assert_eq!(split.len(), 6);
792 assert_eq!(unified.len(), 7);
793 for id in ["context", "added", "removed", "paired"] {
794 assert!(split.iter().any(|row| row.event == line_event(id)));
795 assert!(unified.iter().any(|row| row.event == line_event(id)));
796 }
797 }
798
799 #[test]
800 fn side_specific_spans_keep_a_context_line_one_logical_row() {
801 let files = || {
802 vec![DiffFile::new(
803 "source",
804 "src/lib.rs",
805 [DiffHunk::new(
806 "change",
807 "@@ fixture @@",
808 [DiffLine::new("context", "same").old_spans([CodeSpan {
809 range: 0..4,
810 tone: Tone::Accent,
811 }])],
812 )],
813 )]
814 };
815
816 let split = flatten(files(), DiffPresentation::Split);
817 let unified = flatten(files(), DiffPresentation::Unified);
818
819 assert_eq!(split.len(), 3);
820 assert_eq!(unified.len(), 3);
821 assert_eq!(split[2].event, line_event("context"));
822 assert_eq!(unified[2].event, line_event("context"));
823 }
824
825 fn line_event(id: &str) -> DiffViewEvent {
826 DiffViewEvent::LineActivated {
827 file_id: "source".into(),
828 hunk_id: "change".into(),
829 line_id: id.to_string().into(),
830 }
831 }
832}