Skip to main content

gpui_ai/
code_diff.rs

1//! Code diff viewer: a unified patch as reviewable hunks.
2//!
3//! Agents propose edits; people review them. [`CodeDiff`] shows one file's
4//! patch with old/new line gutters, change tints, a copyable source, and,
5//! when reviewable, an Accept / Reject pair per hunk that reports
6//! [`CodeDiffEvent`]s by file path and hunk index. [`DiffFile::from_unified`]
7//! parses standard unified diffs so the application can hand over what a
8//! tool already produced.
9//!
10//! The code itself is rendered through the upstream syntax-highlighted,
11//! selectable text view; the gutter and change tints are laid out beside it
12//! on the same rem-based line height so numbers and lines stay aligned.
13
14use crate::control::ControlMetricsExt as _;
15use crate::decoration::{DecoratedExt as _, Decoration};
16use crate::motion::{acknowledged_state, disclosure_progress};
17use crate::surface::CardFrameExt as _;
18use crate::{
19    handlers::SharedHandler,
20    status::{StatusBadge, StatusTone},
21    surface::{icon_button_turned, meta},
22    theme::SemanticStyledExt as _,
23};
24use gpui::{
25    App, ClickEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement,
26    ParentElement as _, Rems, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
27    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
28};
29use gpui_component::{
30    ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _,
31    button::{Button, ButtonVariants as _},
32    clipboard::Clipboard,
33    h_flex,
34    text::{TextView, TextViewStyle},
35    v_flex,
36};
37use std::rc::Rc;
38
39/// Line height shared by the gutter and the code so rows stay aligned.
40const LINE_HEIGHT: Rems = Rems(1.5);
41
42/// Width of one line-number gutter column: four digits of the mono
43/// extra-small size, right-aligned, in the type's own unit so the gutter
44/// scales with the code beside it.
45const NUMBER_GUTTER_WIDTH: Rems = Rems(2.25);
46
47/// Width of the +/- sign column between the numbers and the code.
48const SIGN_GUTTER_WIDTH: Rems = Rems(0.75);
49
50/// Whether a line was kept, added, or removed.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum DiffLineKind {
53    /// Present on both sides.
54    Context,
55    /// Present only after the change.
56    Added,
57    /// Present only before the change.
58    Removed,
59}
60
61impl DiffLineKind {
62    fn sign(self) -> &'static str {
63        match self {
64            Self::Context => " ",
65            Self::Added => "+",
66            Self::Removed => "-",
67        }
68    }
69}
70
71/// One line of a hunk with its position on either side.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct DiffLine {
74    kind: DiffLineKind,
75    old_number: Option<u32>,
76    new_number: Option<u32>,
77    text: SharedString,
78}
79
80impl DiffLine {
81    /// Creates a line without positions.
82    pub fn new(kind: DiffLineKind, text: impl Into<SharedString>) -> Self {
83        Self {
84            kind,
85            old_number: None,
86            new_number: None,
87            text: text.into(),
88        }
89    }
90
91    /// Sets the line number before the change.
92    pub fn old_number(mut self, number: u32) -> Self {
93        self.old_number = Some(number);
94        self
95    }
96
97    /// Sets the line number after the change.
98    pub fn new_number(mut self, number: u32) -> Self {
99        self.new_number = Some(number);
100        self
101    }
102
103    /// Returns the line kind.
104    pub fn kind(&self) -> DiffLineKind {
105        self.kind
106    }
107
108    /// Returns the line text without its leading sign.
109    pub fn text(&self) -> &SharedString {
110        &self.text
111    }
112
113    /// Returns the line number before the change.
114    pub fn old_line(&self) -> Option<u32> {
115        self.old_number
116    }
117
118    /// Returns the line number after the change.
119    pub fn new_line(&self) -> Option<u32> {
120        self.new_number
121    }
122}
123
124/// The review decision recorded for a hunk.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
126pub enum HunkReview {
127    /// Not yet decided.
128    #[default]
129    Pending,
130    /// The change was accepted.
131    Accepted,
132    /// The change was rejected.
133    Rejected,
134}
135
136/// A contiguous group of changed lines.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct DiffHunk {
139    header: SharedString,
140    lines: Vec<DiffLine>,
141    review: HunkReview,
142}
143
144impl DiffHunk {
145    /// Creates a hunk with its `@@ … @@` header (or any short caption).
146    pub fn new(header: impl Into<SharedString>) -> Self {
147        Self {
148            header: header.into(),
149            lines: Vec::new(),
150            review: HunkReview::Pending,
151        }
152    }
153
154    /// Sets the lines, in order.
155    pub fn lines(mut self, lines: impl IntoIterator<Item = DiffLine>) -> Self {
156        self.lines = lines.into_iter().collect();
157        self
158    }
159
160    /// Records the review decision.
161    pub fn review(mut self, review: HunkReview) -> Self {
162        self.review = review;
163        self
164    }
165
166    /// Returns the hunk header.
167    pub fn header(&self) -> &SharedString {
168        &self.header
169    }
170
171    /// Returns the lines.
172    pub fn line_refs(&self) -> &[DiffLine] {
173        &self.lines
174    }
175
176    /// Returns the review decision.
177    pub fn review_state(&self) -> HunkReview {
178        self.review
179    }
180
181    /// Counts added and removed lines.
182    pub fn stats(&self) -> DiffStats {
183        let mut stats = DiffStats::default();
184        for line in &self.lines {
185            match line.kind {
186                DiffLineKind::Added => stats.added += 1,
187                DiffLineKind::Removed => stats.removed += 1,
188                DiffLineKind::Context => {}
189            }
190        }
191        stats
192    }
193
194    fn code(&self) -> String {
195        let mut code = String::new();
196        for line in &self.lines {
197            code.push_str(&line.text);
198            code.push('\n');
199        }
200        code
201    }
202
203    fn patch_text(&self) -> String {
204        let mut patch = String::new();
205        patch.push_str(&self.header);
206        patch.push('\n');
207        for line in &self.lines {
208            patch.push_str(line.kind.sign());
209            patch.push_str(&line.text);
210            patch.push('\n');
211        }
212        patch
213    }
214}
215
216/// Added and removed line counts.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
218pub struct DiffStats {
219    /// Lines added.
220    pub added: usize,
221    /// Lines removed.
222    pub removed: usize,
223}
224
225impl DiffStats {
226    /// The conventional `+a −r` summary.
227    pub fn label(self) -> String {
228        format!("+{} \u{2212}{}", self.added, self.removed)
229    }
230}
231
232/// One file's patch.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct DiffFile {
235    path: SharedString,
236    old_path: Option<SharedString>,
237    language: Option<SharedString>,
238    hunks: Vec<DiffHunk>,
239}
240
241impl DiffFile {
242    /// Creates a patch for `path`; the language is inferred from its extension.
243    pub fn new(path: impl Into<SharedString>) -> Self {
244        let path = path.into();
245        Self {
246            language: language_for(&path).map(SharedString::from),
247            path,
248            old_path: None,
249            hunks: Vec::new(),
250        }
251    }
252
253    /// Records the path before a rename.
254    pub fn renamed_from(mut self, old_path: impl Into<SharedString>) -> Self {
255        self.old_path = Some(old_path.into());
256        self
257    }
258
259    /// Overrides the highlighting language.
260    pub fn language(mut self, language: impl Into<SharedString>) -> Self {
261        self.language = Some(language.into());
262        self
263    }
264
265    /// Sets the hunks, in order.
266    pub fn hunks(mut self, hunks: impl IntoIterator<Item = DiffHunk>) -> Self {
267        self.hunks = hunks.into_iter().collect();
268        self
269    }
270
271    /// Returns the file path after the change.
272    pub fn path(&self) -> &SharedString {
273        &self.path
274    }
275
276    /// Returns the path before a rename, if any.
277    pub fn old_path(&self) -> Option<&SharedString> {
278        self.old_path.as_ref()
279    }
280
281    /// Returns the highlighting language, if known.
282    pub fn language_name(&self) -> Option<&SharedString> {
283        self.language.as_ref()
284    }
285
286    /// Returns the hunks.
287    pub fn hunk_refs(&self) -> &[DiffHunk] {
288        &self.hunks
289    }
290
291    /// Sums the hunk statistics.
292    pub fn stats(&self) -> DiffStats {
293        self.hunks.iter().fold(DiffStats::default(), |acc, hunk| {
294            let stats = hunk.stats();
295            DiffStats {
296                added: acc.added + stats.added,
297                removed: acc.removed + stats.removed,
298            }
299        })
300    }
301
302    /// The accessible name: path and statistics.
303    pub fn accessibility_label(&self) -> String {
304        format!("Diff of {}, {}", self.path, self.stats().label())
305    }
306
307    /// Rebuilds a unified patch for this file (what the copy button exports).
308    pub fn to_unified(&self) -> String {
309        let old = self.old_path.as_ref().unwrap_or(&self.path);
310        let mut patch = format!("--- a/{old}\n+++ b/{}\n", self.path);
311        for hunk in &self.hunks {
312            patch.push_str(&hunk.patch_text());
313        }
314        patch
315    }
316
317    /// Parses a unified diff (as produced by `git diff` or `diff -u`) into
318    /// one file per `---`/`+++` pair. Unknown lines are ignored so partial
319    /// tool output still renders.
320    pub fn from_unified(patch: &str) -> Vec<DiffFile> {
321        let mut files: Vec<DiffFile> = Vec::new();
322        let mut pending_old: Option<String> = None;
323        let mut old_line = 0u32;
324        let mut new_line = 0u32;
325
326        for raw in patch.lines() {
327            if let Some(rest) = raw.strip_prefix("--- ") {
328                pending_old = Some(strip_prefix_path(rest));
329                continue;
330            }
331            if let Some(rest) = raw.strip_prefix("+++ ") {
332                let path = strip_prefix_path(rest);
333                let mut file = DiffFile::new(path.clone());
334                if let Some(old) = pending_old.take()
335                    && old != path
336                    && old != "/dev/null"
337                {
338                    file = file.renamed_from(old);
339                }
340                files.push(file);
341                continue;
342            }
343            if let Some(rest) = raw.strip_prefix("@@") {
344                let Some(file) = files.last_mut() else {
345                    continue;
346                };
347                let (old_start, new_start) = parse_hunk_ranges(rest);
348                old_line = old_start;
349                new_line = new_start;
350                file.hunks.push(DiffHunk::new(raw.to_owned()));
351                continue;
352            }
353            let Some(hunk) = files.last_mut().and_then(|file| file.hunks.last_mut()) else {
354                continue;
355            };
356            let (kind, text) = match raw.chars().next() {
357                Some('+') => (DiffLineKind::Added, &raw[1..]),
358                Some('-') => (DiffLineKind::Removed, &raw[1..]),
359                Some(' ') => (DiffLineKind::Context, &raw[1..]),
360                Some('\\') => (DiffLineKind::Context, raw),
361                None => (DiffLineKind::Context, ""),
362                _ => continue,
363            };
364            let mut line = DiffLine::new(kind, text.to_owned());
365            match kind {
366                DiffLineKind::Context => {
367                    line = line.old_number(old_line).new_number(new_line);
368                    old_line += 1;
369                    new_line += 1;
370                }
371                DiffLineKind::Added => {
372                    line = line.new_number(new_line);
373                    new_line += 1;
374                }
375                DiffLineKind::Removed => {
376                    line = line.old_number(old_line);
377                    old_line += 1;
378                }
379            }
380            hunk.lines.push(line);
381        }
382        files
383    }
384}
385
386fn strip_prefix_path(rest: &str) -> String {
387    let path = rest.split('\t').next().unwrap_or(rest).trim();
388    path.strip_prefix("a/")
389        .or_else(|| path.strip_prefix("b/"))
390        .unwrap_or(path)
391        .to_owned()
392}
393
394fn parse_hunk_ranges(rest: &str) -> (u32, u32) {
395    let mut old_start = 1;
396    let mut new_start = 1;
397    for token in rest.split_whitespace().take(2) {
398        let (sign, range) = token.split_at(1);
399        let start = range
400            .split(',')
401            .next()
402            .and_then(|start| start.parse::<u32>().ok())
403            .unwrap_or(1);
404        match sign {
405            "-" => old_start = start,
406            "+" => new_start = start,
407            _ => {}
408        }
409    }
410    (old_start, new_start)
411}
412
413fn language_for(path: &str) -> Option<&'static str> {
414    let extension = path.rsplit_once('.').map(|(_, ext)| ext)?;
415    Some(match extension {
416        "rs" => "rust",
417        "ts" | "tsx" => "typescript",
418        "js" | "jsx" | "mjs" => "javascript",
419        "py" => "python",
420        "go" => "go",
421        "toml" => "toml",
422        "json" => "json",
423        "md" => "markdown",
424        "sh" | "zsh" | "bash" => "bash",
425        "css" => "css",
426        "html" => "html",
427        "yaml" | "yml" => "yaml",
428        "sql" => "sql",
429        "c" | "h" => "c",
430        "cpp" | "hpp" | "cc" => "cpp",
431        "java" => "java",
432        "kt" => "kotlin",
433        "swift" => "swift",
434        "rb" => "ruby",
435        _ => return None,
436    })
437}
438
439/// An interaction emitted by [`CodeDiff`].
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub enum CodeDiffEvent {
442    /// The file header was toggled open or closed.
443    Toggled {
444        /// File path after the change.
445        path: SharedString,
446    },
447    /// A hunk was accepted.
448    HunkAccepted {
449        /// File path after the change.
450        path: SharedString,
451        /// Zero-based hunk index.
452        hunk: usize,
453    },
454    /// A hunk was rejected.
455    HunkRejected {
456        /// File path after the change.
457        path: SharedString,
458        /// Zero-based hunk index.
459        hunk: usize,
460    },
461}
462
463/// One file's patch as a card: header with path and statistics, then each
464/// hunk with gutters, tints, highlighted selectable code, and optional
465/// review controls.
466///
467/// # Example
468///
469/// ```no_run
470/// # use gpui_ai::prelude::*;
471/// # fn example(patch: &str) {
472/// let file = DiffFile::from_unified(patch).remove(0);
473/// CodeDiff::new("patch", &file)
474///     .reviewable(true)
475///     .on_event(|event, _, _| { /* CodeDiffEvent::HunkAccepted { path, hunk } */ });
476/// # }
477/// ```
478#[derive(IntoElement)]
479pub struct CodeDiff {
480    id: ElementId,
481    style: StyleRefinement,
482    decoration: Decoration,
483    file: DiffFile,
484    open: bool,
485    reviewable: bool,
486    on_event: Option<SharedHandler<CodeDiffEvent>>,
487}
488
489impl CodeDiff {
490    /// Layers painted into this diff's frame: one under the content, one
491    /// over it, both clipped to its own shape and neither affecting layout.
492    ///
493    /// This crate ships no effects of its own — what goes in a decoration is
494    /// the application's expression.
495    pub fn decoration(mut self, decoration: Decoration) -> Self {
496        self.decoration = decoration;
497        self
498    }
499
500    /// Creates a viewer for one file, open by default.
501    pub fn new(id: impl Into<ElementId>, file: &DiffFile) -> Self {
502        Self {
503            id: id.into(),
504            style: StyleRefinement::default(),
505            decoration: Decoration::default(),
506            file: file.clone(),
507            open: true,
508            reviewable: false,
509            on_event: None,
510        }
511    }
512
513    /// Shows or hides the hunks; the header toggle reports
514    /// [`CodeDiffEvent::Toggled`] so the application can flip this.
515    pub fn open(mut self, open: bool) -> Self {
516        self.open = open;
517        self
518    }
519
520    /// Shows Accept / Reject for every pending hunk.
521    pub fn reviewable(mut self, reviewable: bool) -> Self {
522        self.reviewable = reviewable;
523        self
524    }
525
526    /// Handles typed interactions. Without a handler the viewer is static.
527    pub fn on_event(
528        mut self,
529        handler: impl Fn(&CodeDiffEvent, &mut Window, &mut App) + 'static,
530    ) -> Self {
531        self.on_event = Some(Rc::new(handler));
532        self
533    }
534}
535
536impl Styled for CodeDiff {
537    fn style(&mut self) -> &mut StyleRefinement {
538        &mut self.style
539    }
540}
541
542impl RenderOnce for CodeDiff {
543    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
544        let tokens = cx.theme().semantic_tokens();
545        // Taken once: each layer is placed at most once, and a
546        // component with no decoration adds no elements at all.
547        let mut decoration = std::mem::take(&mut self.decoration);
548        let file = self.file;
549        let handler = self.on_event;
550        let path = file.path.clone();
551        let path_debug = path.clone();
552        let stats = file.stats();
553        let label: SharedString = file.accessibility_label().into();
554        let language: SharedString = file
555            .language
556            .clone()
557            .unwrap_or_else(|| SharedString::from("text"));
558        let root_id = self.id.clone();
559        let disclosure =
560            disclosure_progress((root_id.clone(), "disclosure"), self.open, window, cx);
561        let disclosure_opacity =
562            crate::motion::disclosure_fade((root_id.clone(), "disclosure"), self.open, window, cx);
563        let showing = self.open;
564
565        let toggle = handler.clone().map(|handler| {
566            let toggle_path = path.clone();
567            let toggle_debug = path.clone();
568            // The same rotating chevron every other disclosure in the
569            // library uses, turned by the same sample the body opens on.
570            // Swapping between two icons could only ever be fully open or
571            // fully closed, and snapped while the panel was still moving.
572            icon_button_turned(
573                (root_id.clone(), "toggle"),
574                IconName::ChevronRight,
575                0.25 * disclosure,
576                if self.open {
577                    "Collapse diff"
578                } else {
579                    "Expand diff"
580                },
581                window,
582                cx,
583            )
584            .debug_selector(move || format!("code-diff-toggle-{toggle_debug}"))
585            .on_click(move |_: &ClickEvent, window, cx| {
586                handler(
587                    &CodeDiffEvent::Toggled {
588                        path: toggle_path.clone(),
589                    },
590                    window,
591                    cx,
592                )
593            })
594        });
595
596        let header = h_flex()
597            .items_center()
598            .gap(tokens.spacing.sm)
599            .px(tokens.spacing.md)
600            .py(tokens.spacing.xs)
601            .bg(cx.theme().muted.opacity(0.35))
602            .when(showing, |this| {
603                this.border_b_1().border_color(cx.theme().border)
604            })
605            .children(toggle)
606            .child(
607                Icon::new(IconName::File)
608                    .xsmall()
609                    .text_color(cx.theme().muted_foreground),
610            )
611            .child(
612                div()
613                    .min_w_0()
614                    .truncate()
615                    .text_token(tokens.typography.sm)
616                    .font_family(cx.theme().mono_font_family.clone())
617                    .text_color(cx.theme().foreground)
618                    .child(path.clone()),
619            )
620            .when_some(file.old_path.clone(), |this, old_path| {
621                this.child(meta(format!("renamed from {old_path}"), cx))
622            })
623            .child(div().flex_1())
624            .child(
625                h_flex()
626                    .flex_none()
627                    .gap(tokens.spacing.xxs)
628                    .text_token(tokens.typography.xs)
629                    .font_family(cx.theme().mono_font_family.clone())
630                    .child(
631                        div()
632                            .text_color(cx.theme().success)
633                            .child(format!("+{}", stats.added)),
634                    )
635                    .child(
636                        div()
637                            .text_color(cx.theme().danger)
638                            .child(format!("\u{2212}{}", stats.removed)),
639                    ),
640            )
641            .child(Clipboard::new((root_id.clone(), "copy")).value(file.to_unified()));
642
643        let mut hunks = Vec::with_capacity(file.hunks.len());
644        if showing {
645            for (index, hunk) in file.hunks.iter().enumerate() {
646                hunks.push(render_hunk(
647                    &root_id,
648                    &path,
649                    index,
650                    hunk,
651                    &language,
652                    self.reviewable,
653                    handler.clone(),
654                    window,
655                    cx,
656                ));
657            }
658        }
659
660        v_flex()
661            .id(self.id)
662            .role(Role::Group)
663            .aria_label(label)
664            .debug_selector(move || format!("code-diff-{path_debug}"))
665            .w_full()
666            .min_w_0()
667            .card_frame(cx)
668            .decoration_under(&mut decoration)
669            .overflow_hidden()
670            .child(header)
671            .when(showing, |this| {
672                // The body is selectable only while the disclosure is open.
673                this.child(
674                    div()
675                        .w_full()
676                        .min_w_0()
677                        .opacity(disclosure_opacity)
678                        .top(tokens.spacing.xxs * (1.0 - disclosure) * crate::motion::travel(cx))
679                        .children(hunks),
680                )
681            })
682            .decoration_over(&mut decoration)
683            .refine_style(&self.style)
684    }
685}
686
687#[allow(clippy::too_many_arguments)]
688fn render_hunk(
689    root_id: &ElementId,
690    path: &SharedString,
691    index: usize,
692    hunk: &DiffHunk,
693    language: &SharedString,
694    reviewable: bool,
695    handler: Option<SharedHandler<CodeDiffEvent>>,
696    window: &mut Window,
697    cx: &mut App,
698) -> gpui::AnyElement {
699    let tokens = cx.theme().semantic_tokens();
700    let hunk_id = ElementId::from((root_id.clone(), format!("hunk-{index}")));
701    let hunk_debug = format!("{path}-{index}");
702    let gutter_debug = hunk_debug.clone();
703    let text_debug = hunk_debug.clone();
704    let stats = hunk.stats();
705    let hunk_label: SharedString =
706        format!("Hunk {} of {}: {}", index + 1, hunk.header, stats.label()).into();
707
708    // Resolution settles in once, after the controlled review changes; a
709    // diff that mounts already reviewed shows its badges at rest, and the
710    // badge's own fixed-slot swap covers a later accepted-to-rejected
711    // change.
712    let resolved = acknowledged_state(
713        ElementId::from((hunk_id.clone(), "resolved")),
714        hunk.review as u64,
715        window,
716        cx,
717    );
718    let review = match (hunk.review, reviewable, handler) {
719        (HunkReview::Accepted, _, _) => Some(
720            div()
721                .opacity(resolved)
722                .child(
723                    StatusBadge::new((hunk_id.clone(), "review"), "Accepted")
724                        .tone(StatusTone::Success),
725                )
726                .into_any_element(),
727        ),
728        (HunkReview::Rejected, _, _) => Some(
729            div()
730                .opacity(resolved)
731                .child(
732                    StatusBadge::new((hunk_id.clone(), "review"), "Rejected")
733                        .tone(StatusTone::Neutral),
734                )
735                .into_any_element(),
736        ),
737        (HunkReview::Pending, true, Some(handler)) => {
738            let accept_path = path.clone();
739            let reject_path = path.clone();
740            let accept_handler = handler.clone();
741            let accept_debug = hunk_debug.clone();
742            let reject_debug = hunk_debug.clone();
743            Some(
744                h_flex()
745                    .flex_none()
746                    .items_center()
747                    .gap(tokens.spacing.xs)
748                    .child(
749                        div()
750                            .debug_selector(move || format!("code-diff-reject-{reject_debug}"))
751                            .child(
752                                Button::new((hunk_id.clone(), "reject"))
753                                    .outline()
754                                    .xsmall()
755                                    .control_metrics(cx)
756                                    .accessibility_id(format!("{path}-reject-{index}"))
757                                    .label("Reject")
758                                    .on_click(move |_: &ClickEvent, window, cx| {
759                                        handler(
760                                            &CodeDiffEvent::HunkRejected {
761                                                path: reject_path.clone(),
762                                                hunk: index,
763                                            },
764                                            window,
765                                            cx,
766                                        )
767                                    }),
768                            ),
769                    )
770                    .child(
771                        div()
772                            .debug_selector(move || format!("code-diff-accept-{accept_debug}"))
773                            .child(
774                                Button::new((hunk_id.clone(), "accept"))
775                                    .primary()
776                                    .xsmall()
777                                    .control_metrics(cx)
778                                    .accessibility_id(format!("{path}-accept-{index}"))
779                                    .label("Accept")
780                                    .on_click(move |_: &ClickEvent, window, cx| {
781                                        accept_handler(
782                                            &CodeDiffEvent::HunkAccepted {
783                                                path: accept_path.clone(),
784                                                hunk: index,
785                                            },
786                                            window,
787                                            cx,
788                                        )
789                                    }),
790                            ),
791                    )
792                    .into_any_element(),
793            )
794        }
795        _ => None,
796    };
797
798    let header = h_flex()
799        .items_center()
800        .gap(tokens.spacing.sm)
801        .px(tokens.spacing.md)
802        .py(tokens.spacing.xxs)
803        .bg(cx.theme().accent.opacity(0.35))
804        .border_t_1()
805        .border_color(cx.theme().border)
806        .child(
807            div()
808                .min_w_0()
809                .truncate()
810                .text_token(tokens.typography.xs)
811                .font_family(cx.theme().mono_font_family.clone())
812                .text_color(cx.theme().muted_foreground)
813                .child(hunk.header.clone()),
814        )
815        .child(div().flex_1())
816        .children(review);
817
818    // Tint rows and number gutters share the code's line height; the text
819    // view's own code-block chrome is stripped so its first line starts at
820    // the same y as the first gutter row.
821    let tints = v_flex()
822        .absolute()
823        .inset_0()
824        .children(hunk.lines.iter().map(|line| {
825            div().w_full().h(LINE_HEIGHT).when_some(
826                match line.kind {
827                    DiffLineKind::Added => Some(cx.theme().success.opacity(0.12)),
828                    DiffLineKind::Removed => Some(cx.theme().danger.opacity(0.12)),
829                    DiffLineKind::Context => None,
830                },
831                |this, color| this.bg(color),
832            )
833        }));
834    let gutter = v_flex()
835        .flex_none()
836        .debug_selector(move || format!("code-diff-gutter-{gutter_debug}"))
837        .px(tokens.spacing.sm)
838        .text_token(tokens.typography.xs)
839        .font_family(cx.theme().mono_font_family.clone())
840        .text_color(cx.theme().muted_foreground)
841        .children(hunk.lines.iter().map(|line| {
842            let sign_color = match line.kind {
843                DiffLineKind::Added => cx.theme().success,
844                DiffLineKind::Removed => cx.theme().danger,
845                DiffLineKind::Context => cx.theme().muted_foreground,
846            };
847            h_flex()
848                .h(LINE_HEIGHT)
849                .items_center()
850                .gap(tokens.spacing.xs)
851                .child(
852                    div()
853                        .w(NUMBER_GUTTER_WIDTH)
854                        .text_right()
855                        .child(line.old_number.map_or(String::new(), |n| n.to_string())),
856                )
857                .child(
858                    div()
859                        .w(NUMBER_GUTTER_WIDTH)
860                        .text_right()
861                        .child(line.new_number.map_or(String::new(), |n| n.to_string())),
862                )
863                .child(
864                    div()
865                        .w(SIGN_GUTTER_WIDTH)
866                        .text_center()
867                        .font_weight(FontWeight::SEMIBOLD)
868                        .text_color(sign_color)
869                        .child(line.kind.sign().trim().to_owned()),
870                )
871        }));
872    let fence = "`".repeat(
873        (hunk
874            .lines
875            .iter()
876            .flat_map(|line| line.text.split(|c| c != '`'))
877            .map(str::len)
878            .max()
879            .unwrap_or(0)
880            + 1)
881        .max(3),
882    );
883    let source = format!("{fence}{language}\n{}{fence}", hunk.code());
884    let code_style = StyleRefinement::default()
885        .p_0()
886        .m_0()
887        .rounded(gpui::Pixels::ZERO)
888        .bg(cx.theme().transparent)
889        .line_height(LINE_HEIGHT);
890    let text = div()
891        .id((hunk_id.clone(), "scroll"))
892        .debug_selector(move || format!("code-diff-text-{text_debug}"))
893        .flex_1()
894        .min_w_0()
895        .overflow_x_scroll()
896        .whitespace_nowrap()
897        .line_height(LINE_HEIGHT)
898        .pr(tokens.spacing.md)
899        .child(
900            TextView::markdown((hunk_id.clone(), "code"), source)
901                .style(TextViewStyle::default().code_block(code_style))
902                .selectable(true),
903        );
904
905    v_flex()
906        .id(hunk_id)
907        .role(Role::Group)
908        .aria_label(hunk_label)
909        .debug_selector(move || format!("code-diff-hunk-{hunk_debug}"))
910        .w_full()
911        .min_w_0()
912        .child(header)
913        .child(
914            h_flex()
915                .relative()
916                .w_full()
917                .min_w_0()
918                .items_start()
919                .line_height(LINE_HEIGHT)
920                .child(tints)
921                .child(gutter)
922                .child(text),
923        )
924        .into_any_element()
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930
931    const PATCH: &str = "diff --git a/src/pricing.rs b/src/pricing.rs\n--- a/src/pricing.rs\n+++ b/src/pricing.rs\n@@ -1,4 +1,5 @@ fn unit_price\n fn unit_price(order: &Order) -> Money {\n-    order.total / order.units\n+    let units = order.units.max(1);\n+    order.total / units\n }\n \n@@ -10,3 +11,3 @@\n fn discount() -> f32 {\n-    0.05\n+    0.07\n }\n";
932
933    #[test]
934    fn unified_patches_parse_into_numbered_hunks() {
935        let files = DiffFile::from_unified(PATCH);
936        assert_eq!(files.len(), 1);
937        let file = &files[0];
938        assert_eq!(file.path().as_ref(), "src/pricing.rs");
939        assert_eq!(file.language_name().map(|l| l.as_ref()), Some("rust"));
940        assert_eq!(file.hunk_refs().len(), 2);
941        assert_eq!(
942            file.stats(),
943            DiffStats {
944                added: 3,
945                removed: 2
946            }
947        );
948        assert_eq!(
949            file.accessibility_label(),
950            "Diff of src/pricing.rs, +3 \u{2212}2"
951        );
952
953        let first = &file.hunk_refs()[0];
954        assert_eq!(first.header().as_ref(), "@@ -1,4 +1,5 @@ fn unit_price");
955        let lines = first.line_refs();
956        assert_eq!(lines[0].kind(), DiffLineKind::Context);
957        assert_eq!(
958            (lines[0].old_line(), lines[0].new_line()),
959            (Some(1), Some(1))
960        );
961        assert_eq!(lines[1].kind(), DiffLineKind::Removed);
962        assert_eq!((lines[1].old_line(), lines[1].new_line()), (Some(2), None));
963        assert_eq!(lines[2].kind(), DiffLineKind::Added);
964        assert_eq!((lines[2].old_line(), lines[2].new_line()), (None, Some(2)));
965        assert_eq!(lines[3].text().as_ref(), "    order.total / units");
966        assert_eq!(
967            (lines[4].old_line(), lines[4].new_line()),
968            (Some(3), Some(4))
969        );
970
971        let second = &file.hunk_refs()[1];
972        assert_eq!(second.line_refs()[0].old_line(), Some(10));
973        assert_eq!(second.line_refs()[0].new_line(), Some(11));
974    }
975
976    #[test]
977    fn round_trips_to_a_unified_patch() {
978        let file = DiffFile::from_unified(PATCH).remove(0);
979        let unified = file.to_unified();
980        assert!(unified.starts_with(
981            "--- a/src/pricing.rs\n+++ b/src/pricing.rs\n@@ -1,4 +1,5 @@ fn unit_price\n"
982        ));
983        assert!(
984            unified
985                .contains("-    order.total / order.units\n+    let units = order.units.max(1);\n")
986        );
987        let again = DiffFile::from_unified(&unified).remove(0);
988        assert_eq!(again, file);
989    }
990
991    #[test]
992    fn renames_and_unknown_lines_are_tolerated() {
993        let files = DiffFile::from_unified(
994            "--- a/old.md\n+++ b/new.md\nindex 1..2\n@@ -1 +1 @@\n-old\n+new\n",
995        );
996        assert_eq!(files[0].old_path().map(|p| p.as_ref()), Some("old.md"));
997        assert_eq!(files[0].path().as_ref(), "new.md");
998        assert_eq!(files[0].hunk_refs()[0].line_refs().len(), 2);
999        assert!(DiffFile::from_unified("not a diff").is_empty());
1000    }
1001}