Skip to main content

gpui_kit/content/
diff_view.rs

1//! Read-only presentation of a diff the caller already computed.
2//!
3//! Files, hunks, lines, line identities, old and new line numbers, marks and
4//! pre-classified code spans all come from the caller. This module computes no
5//! diff, parses no syntax, applies no patch and reads no filesystem.
6//!
7//! # One renderer, two arrangements
8//!
9//! Unified and split presentation use the same caller-supplied logical rows
10//! and the same line-side renderer. For a replacement, the caller supplies
11//! the aligned old and new sides: split mode places them opposite one another,
12//! while unified mode places them on consecutive fixed rows. The component
13//! never guesses which removal belongs with which addition.
14//!
15//! # Large data
16//!
17//! The hierarchy is flattened once per render, which walks all caller-owned
18//! files, hunks and lines. The resulting fixed-height rows are handed to the
19//! virtualized [`List`], so only viewport rows are laid out
20//! or published. The explicit price is the same one paid by virtualized
21//! `CodeView`: long lines are clipped and do not horizontally scroll. This is
22//! suitable for a large already-materialized diff, not for lazy diff loading.
23
24use 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/// How the same caller-supplied lines are arranged.
47#[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/// The caller's claim about one diff line.
64#[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/// One side of a caller-supplied diff line.
99#[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/// One stable, caller-supplied logical diff row.
119///
120/// A context row carries the same text on both sides. A pure addition or
121/// removal carries one side. A replacement carries both different sides, so
122/// split presentation can align them without this component deciding which
123/// removal belongs with which addition.
124#[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    /// A context line. The text begins on both sides; old and new numbers are
133    /// supplied independently.
134    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    /// A pure addition supplied by the caller.
144    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    /// A pure removal supplied by the caller.
153    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    /// An aligned replacement supplied by the caller. Unified presentation
162    /// draws its removed and added sides as consecutive rows; split
163    /// presentation draws them opposite one another.
164    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    /// Pre-classified spans for both sides. Use `old_spans` and `new_spans`
191    /// when an aligned replacement has different classifications.
192    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/// One caller-supplied hunk.
243#[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/// One caller-supplied file and its hunks.
265#[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/// An action on caller-owned diff identity.
287#[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/// A virtualized, read-only diff presentation.
304#[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    /// Bounds and virtualizes the view to this many fixed-height rows. File
343    /// and hunk headers each occupy one row too.
344    pub fn visible_rows(mut self, rows: usize) -> Self {
345        self.visible_rows = rows.max(1);
346        self
347    }
348
349    /// Reports file, hunk and line actions without changing or applying the
350    /// diff.
351    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                    // Source and paths stay out of diagnostic snapshots. Stable
425                    // business ids and the row kind remain addressable.
426                    .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}