Skip to main content

gpui_component/diff/
mod.rs

1//! Readonly, virtualized patch display inspired by Diffs from Pierre.
2//! Parse an externally supplied patch into [`DiffFile`]s, retain one
3//! [`DiffState`] for all of them in the owner, and build [`Diff`] during rendering.
4mod conflict;
5mod document;
6mod parser;
7#[cfg(test)]
8mod parser_tests;
9mod presentation;
10mod selection;
11mod state;
12
13pub use conflict::{DiffConflict, DiffConflictResolution};
14pub use document::{DiffFile, DiffFileStatus, DiffLinePosition, DiffLineRange, DiffSide};
15pub use parser::DiffParseError;
16pub use presentation::DiffInlineUnit;
17pub use state::{DiffEvent, DiffState};
18
19use std::{collections::HashMap, rc::Rc, sync::Arc};
20
21use gpui::{
22    AnyElement, App, Axis, ClickEvent, ClipboardItem, Context, ElementId, Entity, Focusable as _,
23    HighlightStyle, Hsla, InteractiveElement as _, IntoElement, KeyBinding, ListOffset,
24    MouseButton, ParentElement as _, Pixels, RenderOnce, SharedString,
25    StatefulInteractiveElement as _, StyleRefinement, Styled, TextRun, Window, div, list, point,
26    prelude::FluentBuilder as _, px,
27};
28use gpui_base::TestSupportExt as _;
29use rust_i18n::t;
30
31use crate::{
32    ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _,
33    button::{Button, ButtonVariants as _},
34    h_flex,
35    input::{Copy, SelectAll},
36    scroll::{ScrollableMask, Scrollbar},
37    v_flex,
38};
39use actions::*;
40use conflict::ConflictPart;
41use presentation::FilePresentation;
42use selection::{CodeText, SelectionLayer};
43use state::{DisplayRow, FoldExpansion, LayoutKey, SelectedLines};
44
45// Private: bindings serve the focused code body only.
46mod actions {
47    gpui::actions!(
48        diff,
49        [
50            ScrollUp,
51            ScrollDown,
52            ScrollLeft,
53            ScrollRight,
54            PageUp,
55            PageDown,
56            First,
57            Last,
58            ExtendUp,
59            ExtendDown
60        ]
61    );
62}
63
64pub(crate) fn init(cx: &mut App) {
65    cx.bind_keys([
66        KeyBinding::new("up", ScrollUp, Some("Diff")),
67        KeyBinding::new("down", ScrollDown, Some("Diff")),
68        KeyBinding::new("left", ScrollLeft, Some("Diff")),
69        KeyBinding::new("right", ScrollRight, Some("Diff")),
70        KeyBinding::new("pageup", PageUp, Some("Diff")),
71        KeyBinding::new("pagedown", PageDown, Some("Diff")),
72        KeyBinding::new("home", First, Some("Diff")),
73        KeyBinding::new("end", Last, Some("Diff")),
74        KeyBinding::new("shift-up", ExtendUp, Some("Diff")),
75        KeyBinding::new("shift-down", ExtendDown, Some("Diff")),
76        KeyBinding::new("secondary-c", Copy, Some("Diff")),
77        KeyBinding::new("secondary-a", SelectAll, Some("Diff")),
78    ]);
79}
80
81/// How a [`Diff`] presents the two sides of each file.
82#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum DiffMode {
85    /// The original and modified source side by side.
86    Split,
87    /// One column, with deleted lines before added lines.
88    #[default]
89    Unified,
90}
91
92/// Which part of a row is emphasized while the pointer is over it.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum DiffHoverHighlight {
96    #[default]
97    None,
98    /// The whole line.
99    Line,
100    /// The line-number gutter.
101    LineNumber,
102    /// The line and its line number.
103    Both,
104}
105
106/// How the start of each hunk is marked.
107#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum DiffHunkSeparator {
110    /// The `@@` header, including the enclosing scope Git reports.
111    #[default]
112    Metadata,
113    /// The count of lines the patch does not supply before the hunk.
114    LineInfo,
115    /// A thin divider.
116    Simple,
117}
118
119/// How changed lines are marked beside the code.
120#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
121#[non_exhaustive]
122pub enum DiffChangeIndicator {
123    /// `+` and `−` signs.
124    #[default]
125    Signs,
126    /// A colored bar.
127    Bars,
128    /// No marker.
129    None,
130}
131
132/// Application-owned content attached to a source line or to a whole file.
133///
134/// The stable ID belongs to the annotation itself, while the target uses the
135/// file path and, for a line, its side and one-based source line, independent
136/// of folding.
137#[derive(Clone)]
138pub struct DiffAnnotation {
139    id: ElementId,
140    path: SharedString,
141    position: Option<DiffLinePosition>,
142}
143
144impl DiffAnnotation {
145    /// Attaches content beneath a source line.
146    pub fn line(id: impl Into<ElementId>, position: DiffLinePosition) -> Self {
147        Self {
148            id: id.into(),
149            path: position.path().clone(),
150            position: Some(position),
151        }
152    }
153    /// Attaches content beneath the header of the file at `path`.
154    pub fn file(id: impl Into<ElementId>, path: impl Into<SharedString>) -> Self {
155        Self {
156            id: id.into(),
157            path: path.into(),
158            position: None,
159        }
160    }
161    pub fn id(&self) -> &ElementId {
162        &self.id
163    }
164    /// The [`DiffFile::path`] of the annotated file.
165    pub fn path(&self) -> &SharedString {
166        &self.path
167    }
168    /// The annotated line, or `None` for a file annotation.
169    pub fn position(&self) -> Option<&DiffLinePosition> {
170        self.position.as_ref()
171    }
172}
173
174type FileRenderer = Rc<dyn Fn(&DiffFile, &mut Window, &mut App) -> AnyElement>;
175type AnnotationRenderer = Rc<dyn Fn(&DiffAnnotation, &mut Window, &mut App) -> AnyElement>;
176type RangeHandler = Rc<dyn Fn(&DiffLineRange, &mut Window, &mut App)>;
177type ClickHandler = Rc<dyn Fn(&DiffLinePosition, &ClickEvent, &mut Window, &mut App)>;
178type HoverHandler = Rc<dyn Fn(Option<&DiffLinePosition>, &mut Window, &mut App)>;
179
180#[derive(Default)]
181struct HeaderSlots {
182    content: Option<FileRenderer>,
183    prefix: Option<FileRenderer>,
184    title_suffix: Option<FileRenderer>,
185    suffix: Option<FileRenderer>,
186}
187
188#[derive(Default)]
189struct Annotations {
190    items: Vec<DiffAnnotation>,
191    lines: HashMap<DiffLinePosition, Vec<usize>>,
192    files: HashMap<SharedString, Vec<usize>>,
193}
194
195/// A themed readonly code-review surface in split or unified mode.
196///
197/// All files of the supplied state share one virtualized list, each introduced
198/// by its header. The component owns its scrolling, so give it a bounded
199/// height. Long lines scroll horizontally unless [`Diff::soft_wrap`] is set;
200/// file headers and review annotations are explicit slots, and application
201/// commands remain owned by the application.
202#[derive(IntoElement)]
203pub struct Diff {
204    state: Entity<DiffState>,
205    style: StyleRefinement,
206    line_number: bool,
207    syntax_highlight: bool,
208    header_visible: bool,
209    hover_highlight: DiffHoverHighlight,
210    hunk_separator: DiffHunkSeparator,
211    change_indicator: DiffChangeIndicator,
212    change_background: bool,
213    soft_wrap: bool,
214    header: HeaderSlots,
215    annotations: Rc<Annotations>,
216    annotation_content: Option<AnnotationRenderer>,
217    on_add_annotation: Option<RangeHandler>,
218    on_line_click: Option<ClickHandler>,
219    on_line_hover: Option<HoverHandler>,
220}
221
222fn file_renderer<F, E>(render: F) -> FileRenderer
223where
224    F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static,
225    E: IntoElement,
226{
227    Rc::new(move |file, window, cx| render(file, window, cx).into_any_element())
228}
229
230impl Diff {
231    pub fn new(state: &Entity<DiffState>) -> Self {
232        Self {
233            state: state.clone(),
234            style: StyleRefinement::default(),
235            line_number: true,
236            syntax_highlight: true,
237            header_visible: true,
238            hover_highlight: DiffHoverHighlight::default(),
239            hunk_separator: DiffHunkSeparator::default(),
240            change_indicator: DiffChangeIndicator::default(),
241            change_background: true,
242            soft_wrap: false,
243            header: HeaderSlots::default(),
244            annotations: Rc::default(),
245            annotation_content: None,
246            on_add_annotation: None,
247            on_line_click: None,
248            on_line_hover: None,
249        }
250    }
251    /// Shows the original and modified line-number lanes. Default is true.
252    pub fn line_number(mut self, value: bool) -> Self {
253        self.line_number = value;
254        self
255    }
256    /// Emphasizes syntax once the state has prepared it. Default is true.
257    pub fn syntax_highlight(mut self, value: bool) -> Self {
258        self.syntax_highlight = value;
259        self
260    }
261    /// Shows a header above each file. Default is true.
262    pub fn header_visible(mut self, visible: bool) -> Self {
263        self.header_visible = visible;
264        self
265    }
266    /// Emphasizes the line, its number, or both under the pointer. Default is none.
267    pub fn hover_highlight(mut self, highlight: DiffHoverHighlight) -> Self {
268        self.hover_highlight = highlight;
269        self
270    }
271    /// Marks the start of each hunk. Default is [`DiffHunkSeparator::Metadata`].
272    pub fn hunk_separator(mut self, separator: DiffHunkSeparator) -> Self {
273        self.hunk_separator = separator;
274        self
275    }
276    /// Marks changed lines beside the code. Default is [`DiffChangeIndicator::Signs`].
277    pub fn change_indicator(mut self, indicator: DiffChangeIndicator) -> Self {
278        self.change_indicator = indicator;
279        self
280    }
281    /// Tints changed lines. Default is true. Inline changes stay emphasized.
282    pub fn change_background(mut self, value: bool) -> Self {
283        self.change_background = value;
284        self
285    }
286    /// Wraps long lines at the column width instead of scrolling horizontally.
287    /// Default is false.
288    pub fn soft_wrap(mut self, value: bool) -> Self {
289        self.soft_wrap = value;
290        self
291    }
292    /// Supplies annotations with stable identities and targets.
293    ///
294    /// Rows re-measure when annotations are added, removed or replaced, and
295    /// whenever they are visible, so annotation content may change height freely.
296    pub fn annotations(mut self, annotations: impl IntoIterator<Item = DiffAnnotation>) -> Self {
297        let mut index = Annotations {
298            items: annotations.into_iter().collect(),
299            ..Annotations::default()
300        };
301        for (ix, annotation) in index.items.iter().enumerate() {
302            match &annotation.position {
303                Some(position) => index.lines.entry(position.clone()).or_default().push(ix),
304                None => index
305                    .files
306                    .entry(annotation.path.clone())
307                    .or_default()
308                    .push(ix),
309            }
310        }
311        self.annotations = Rc::new(index);
312        self
313    }
314    /// Renders each supplied annotation. Application state and commands stay with the owner.
315    pub fn render_annotation<F, E>(mut self, render: F) -> Self
316    where
317        F: Fn(&DiffAnnotation, &mut Window, &mut App) -> E + 'static,
318        E: IntoElement,
319    {
320        self.annotation_content = Some(Rc::new(move |annotation, window, cx| {
321            render(annotation, window, cx).into_any_element()
322        }));
323        self
324    }
325    /// Replaces the content of each file header, keeping its shell, separator
326    /// and collapse control.
327    pub fn render_header<F, E>(mut self, render: F) -> Self
328    where
329        F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static,
330        E: IntoElement,
331    {
332        self.header.content = Some(file_renderer(render));
333        self
334    }
335    /// Inserts content before the path in the default header.
336    pub fn render_header_prefix<F, E>(mut self, render: F) -> Self
337    where
338        F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static,
339        E: IntoElement,
340    {
341        self.header.prefix = Some(file_renderer(render));
342        self
343    }
344    /// Inserts compact content immediately after the path in the default header.
345    pub fn render_header_title_suffix<F, E>(mut self, render: F) -> Self
346    where
347        F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static,
348        E: IntoElement,
349    {
350        self.header.title_suffix = Some(file_renderer(render));
351        self
352    }
353    /// Inserts trailing content after the change statistics in the default header.
354    pub fn render_header_suffix<F, E>(mut self, render: F) -> Self
355    where
356        F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static,
357        E: IntoElement,
358    {
359        self.header.suffix = Some(file_renderer(render));
360        self
361    }
362    /// Compatibility alias for [`Self::render_annotation`].
363    pub fn annotation_content<F, E>(self, render: F) -> Self
364    where
365        F: Fn(&DiffAnnotation, &mut Window, &mut App) -> E + 'static,
366        E: IntoElement,
367    {
368        self.render_annotation(render)
369    }
370
371    /// Compatibility alias for [`Self::render_header`].
372    pub fn header<F, E>(self, render: F) -> Self
373    where
374        F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static,
375        E: IntoElement,
376    {
377        self.render_header(render)
378    }
379
380    /// Compatibility alias for [`Self::render_header_prefix`].
381    pub fn header_prefix<F, E>(self, render: F) -> Self
382    where
383        F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static,
384        E: IntoElement,
385    {
386        self.render_header_prefix(render)
387    }
388
389    /// Compatibility alias for [`Self::render_header_title_suffix`].
390    pub fn header_title_suffix<F, E>(self, render: F) -> Self
391    where
392        F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static,
393        E: IntoElement,
394    {
395        self.render_header_title_suffix(render)
396    }
397
398    /// Compatibility alias for [`Self::render_header_suffix`].
399    pub fn header_suffix<F, E>(self, render: F) -> Self
400    where
401        F: Fn(&DiffFile, &mut Window, &mut App) -> E + 'static,
402        E: IntoElement,
403    {
404        self.render_header_suffix(render)
405    }
406
407    /// Shows an add button beside the hovered line and at the end of the
408    /// selected lines. The handler receives the selected range when the line
409    /// is part of it, or that line alone.
410    pub fn on_add_annotation<F>(mut self, handler: F) -> Self
411    where
412        F: Fn(&DiffLineRange, &mut Window, &mut App) + 'static,
413    {
414        self.on_add_annotation = Some(Rc::new(handler));
415        self
416    }
417    /// Handles a click on a line's code, outside a text-selection drag.
418    pub fn on_line_click<F>(mut self, handler: F) -> Self
419    where
420        F: Fn(&DiffLinePosition, &ClickEvent, &mut Window, &mut App) + 'static,
421    {
422        self.on_line_click = Some(Rc::new(handler));
423        self
424    }
425    /// Reports the line under the pointer, or `None` when the pointer leaves it.
426    pub fn on_line_hover<F>(mut self, handler: F) -> Self
427    where
428        F: Fn(Option<&DiffLinePosition>, &mut Window, &mut App) + 'static,
429    {
430        self.on_line_hover = Some(Rc::new(handler));
431        self
432    }
433}
434impl Styled for Diff {
435    fn style(&mut self) -> &mut StyleRefinement {
436        &mut self.style
437    }
438}
439
440impl RenderOnce for Diff {
441    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
442        let state = self.state.clone();
443        state.update(cx, |state, cx| render_diff(self, state, window, cx))
444    }
445}
446
447fn render_diff(
448    props: Diff,
449    state: &mut DiffState,
450    window: &mut Window,
451    cx: &mut Context<DiffState>,
452) -> AnyElement {
453    state.set_window(window.window_handle().window_id());
454    state.ensure_presentation(cx);
455    let rem = window.rem_size();
456    let font_size = rem * (f32::from(cx.theme().mono_font_size) / 16.);
457    let font_family = cx.theme().mono_font_family.clone();
458    let row_height = font_size * 1.6;
459    let digits = state
460        .files()
461        .iter()
462        .map(|file| file.line_number_digits())
463        .max()
464        .unwrap_or(1);
465    let gutter_width = if props.line_number {
466        font_size * 0.7 * digits as f32 + rem
467    } else {
468        px(0.)
469    };
470    let mode = state.mode();
471    let soft_wrap = props.soft_wrap;
472    let layout = LayoutKey::new(
473        rem,
474        font_size,
475        font_family.clone(),
476        mode,
477        props.line_number,
478        soft_wrap,
479    );
480    if state.update_layout(layout, row_height) {
481        // Wrapped rows take the viewport width instead of their longest line.
482        let width = if soft_wrap {
483            px(0.)
484        } else {
485            measure_code_width(
486                state.files(),
487                font_size,
488                &font_family,
489                gutter_width,
490                mode,
491                window,
492                cx,
493            )
494        };
495        state.set_content_width(width);
496    }
497    state.sync_annotations(&props.annotations.items);
498    let content_width = state.content_width().max(state.viewport().width);
499    let column_width = if mode == DiffMode::Split {
500        content_width / 2.
501    } else {
502        content_width
503    };
504    let code = Rc::new(CodePresentation {
505        files: state.files().to_vec(),
506        presentations: state.presentations().to_vec(),
507        mode,
508        content_width,
509        column_width,
510        font_size,
511        row_height,
512        gutter_width,
513        rem,
514        expansion_lines: state.expansion_lines(),
515        line_number: props.line_number,
516        syntax_highlight: props.syntax_highlight,
517        header_visible: props.header_visible,
518        hover_highlight: props.hover_highlight,
519        hunk_separator: props.hunk_separator,
520        change_indicator: props.change_indicator,
521        change_background: props.change_background,
522        soft_wrap,
523        header: props.header,
524        annotations: props.annotations.clone(),
525        annotation_content: props.annotation_content,
526        on_add_annotation: props.on_add_annotation,
527        on_line_click: props.on_line_click,
528        on_line_hover: props.on_line_hover,
529        selection: state.selection().clone(),
530        selected_lines: state.selected_lines(),
531        selected: state.selected_span(),
532        geometry: state.geometry().clone(),
533        state: cx.entity(),
534    });
535    let rows = state.rows().clone();
536    let scrollbar = state.list().clone();
537    let horizontal = state.horizontal_scroll().clone();
538    let has_rows = !rows.is_empty();
539    let label = match state.files() {
540        [file] => t!("Diff.Viewer", name = file.path().as_str()).to_string(),
541        files => t!("Diff.ViewerFiles", count = files.len()).to_string(),
542    };
543    let body = div()
544        .id("diff-body")
545        .relative()
546        .min_h_0()
547        .min_w_0()
548        .flex_1()
549        .child(SelectionLayer::new(
550            div()
551                .id("diff-horizontal")
552                .size_full()
553                // Preserve offset tracking and clipping without GPUI's
554                // single-axis wheel remapping. The sibling mask owns X input.
555                .when(!soft_wrap, |this| this.overflow_x_hidden())
556                .track_scroll(&horizontal)
557                .child(
558                    list(scrollbar.clone(), move |ix, window, cx| {
559                        let row = &rows[ix];
560                        div()
561                            .id(("diff-file", row.file()))
562                            .w_full()
563                            .child(render_row(row, code.clone(), window, cx))
564                            .into_any_element()
565                    })
566                    .w(content_width)
567                    .h_full(),
568                )
569                .into_any_element(),
570            scrollbar.clone(),
571            horizontal.clone(),
572            state.selection().clone(),
573            state.geometry().clone(),
574        ))
575        .when(has_rows && !soft_wrap, |this| {
576            this.child(
577                ScrollableMask::new(Axis::Horizontal, &horizontal).id("diff-horizontal-wheel"),
578            )
579        })
580        .when(has_rows, |this| {
581            this.child(Scrollbar::vertical(&scrollbar))
582                .when(!soft_wrap, |this| {
583                    this.child(Scrollbar::horizontal(&horizontal))
584                })
585        })
586        // A line-number drag may finish anywhere, including outside the body.
587        .on_mouse_up(
588            MouseButton::Left,
589            cx.listener(|this, _, _, cx| this.end_line_selection(cx)),
590        )
591        .on_mouse_up_out(
592            MouseButton::Left,
593            cx.listener(|this, _, _, cx| this.end_line_selection(cx)),
594        )
595        .on_action(cx.listener(|this, _: &ScrollUp, window, cx| {
596            if !body_focused(this, window, cx) {
597                return;
598            }
599            this.list().scroll_by(-window.rem_size() * 1.5);
600            cx.notify();
601        }))
602        .on_action(cx.listener(|this, _: &ScrollDown, window, cx| {
603            if !body_focused(this, window, cx) {
604                return;
605            }
606            this.list().scroll_by(window.rem_size() * 1.5);
607            cx.notify();
608        }))
609        .on_action(cx.listener(|this, _: &ScrollLeft, window, cx| {
610            if !body_focused(this, window, cx) {
611                return;
612            }
613            let offset = this.horizontal_scroll().offset();
614            this.horizontal_scroll()
615                .set_offset(point(offset.x + window.rem_size() * 3., offset.y));
616            cx.notify();
617        }))
618        .on_action(cx.listener(|this, _: &ScrollRight, window, cx| {
619            if !body_focused(this, window, cx) {
620                return;
621            }
622            let offset = this.horizontal_scroll().offset();
623            this.horizontal_scroll()
624                .set_offset(point(offset.x - window.rem_size() * 3., offset.y));
625            cx.notify();
626        }))
627        .on_action(cx.listener(|this, _: &PageUp, window, cx| {
628            if !body_focused(this, window, cx) {
629                return;
630            }
631            this.list().scroll_by(-this.viewport().height * 0.9);
632            cx.notify();
633        }))
634        .on_action(cx.listener(|this, _: &PageDown, window, cx| {
635            if !body_focused(this, window, cx) {
636                return;
637            }
638            this.list().scroll_by(this.viewport().height * 0.9);
639            cx.notify();
640        }))
641        .on_action(cx.listener(|this, _: &First, window, cx| {
642            if !body_focused(this, window, cx) {
643                return;
644            }
645            this.list().scroll_to(ListOffset::default());
646            cx.notify();
647        }))
648        .on_action(cx.listener(|this, _: &Last, window, cx| {
649            if !body_focused(this, window, cx) {
650                return;
651            }
652            this.list().scroll_to_end();
653            cx.notify();
654        }))
655        .on_action(cx.listener(|this, _: &ExtendUp, window, cx| {
656            if !body_focused(this, window, cx) {
657                return;
658            }
659            this.keyboard_select(-1, true, window, cx)
660        }))
661        .on_action(cx.listener(|this, _: &ExtendDown, window, cx| {
662            if !body_focused(this, window, cx) {
663                return;
664            }
665            this.keyboard_select(1, true, window, cx)
666        }))
667        .on_action(cx.listener(|this, _: &SelectAll, window, cx| {
668            if body_focused(this, window, cx) {
669                this.select_all(window, cx);
670            }
671        }))
672        .on_action(cx.listener(|this, _: &Copy, window, cx| {
673            if !body_focused(this, window, cx) {
674                return;
675            }
676            let text = this.selected_text(cx);
677            if text.is_empty() {
678                cx.propagate();
679            } else {
680                cx.write_to_clipboard(ClipboardItem::new_string(text));
681            }
682        }))
683        .child(
684            gpui::canvas(
685                {
686                    let state = cx.entity();
687                    move |bounds, _, cx| {
688                        state.update(cx, |state, cx| {
689                            // The estimate shapes the widest candidates only;
690                            // grow to fit any wider line once it is painted.
691                            let painted = if soft_wrap {
692                                px(0.)
693                            } else {
694                                let painted = state.geometry().borrow().painted_width();
695                                code_width(painted, gutter_width, rem, mode)
696                            };
697                            if state.record_paint(bounds.size, painted) {
698                                cx.notify();
699                            }
700                        });
701                    }
702                },
703                |_, _, _, _| {},
704            )
705            .absolute()
706            .size_full(),
707        )
708        .test_support()
709        .track_focus(&state.focus_handle(cx))
710        .key_context("Diff")
711        .role(gpui::accesskit::Role::Document)
712        .aria_label(label);
713    v_flex()
714        .id(("diff", cx.entity_id()))
715        .size_full()
716        .min_w_0()
717        .min_h_0()
718        .bg(cx.theme().background)
719        .text_color(cx.theme().foreground)
720        .text_sm()
721        .border_1()
722        .border_color(cx.theme().border)
723        .child(body)
724        .refine_style(&props.style)
725        .into_any_element()
726}
727
728fn body_focused(state: &DiffState, window: &Window, cx: &mut Context<DiffState>) -> bool {
729    if state.focus_handle(cx).is_focused(window) {
730        true
731    } else {
732        cx.propagate();
733        false
734    }
735}
736
737struct CodePresentation {
738    files: Vec<DiffFile>,
739    presentations: Vec<Option<Arc<FilePresentation>>>,
740    mode: DiffMode,
741    content_width: Pixels,
742    column_width: Pixels,
743    font_size: Pixels,
744    row_height: Pixels,
745    gutter_width: Pixels,
746    rem: Pixels,
747    expansion_lines: usize,
748    line_number: bool,
749    syntax_highlight: bool,
750    header_visible: bool,
751    hover_highlight: DiffHoverHighlight,
752    hunk_separator: DiffHunkSeparator,
753    change_indicator: DiffChangeIndicator,
754    change_background: bool,
755    soft_wrap: bool,
756    header: HeaderSlots,
757    annotations: Rc<Annotations>,
758    annotation_content: Option<AnnotationRenderer>,
759    on_add_annotation: Option<RangeHandler>,
760    on_line_click: Option<ClickHandler>,
761    on_line_hover: Option<HoverHandler>,
762    selection: gpui_base::TextSelectionHandle,
763    selected_lines: Option<DiffLineRange>,
764    selected: Option<SelectedLines>,
765    geometry: Rc<std::cell::RefCell<selection::SelectionGeometry>>,
766    state: Entity<DiffState>,
767}
768
769impl CodePresentation {
770    fn position(&self, file: usize, side: DiffSide, ix: usize) -> DiffLinePosition {
771        let document = &self.files[file];
772        DiffLinePosition::new(
773            document.path().clone(),
774            side,
775            document.line_number(side, ix),
776        )
777    }
778
779    fn is_selected(&self, file: usize, side: DiffSide, ix: usize) -> bool {
780        self.selected
781            .as_ref()
782            .is_some_and(|selected| selected.contains(&self.files, file, side, ix))
783    }
784}
785
786/// Total row width for code of `width` in the current mode.
787fn code_width(width: Pixels, gutter_width: Pixels, rem: Pixels, mode: DiffMode) -> Pixels {
788    if mode == DiffMode::Split {
789        (width + gutter_width + rem * 2.) * 2.
790    } else {
791        width + gutter_width * 2. + rem * 2.
792    }
793}
794
795/// Estimates content width from the lines widest in display cells, then
796/// shapes those candidates with the code font for resolved geometry.
797fn measure_code_width(
798    files: &[DiffFile],
799    font_size: Pixels,
800    family: &SharedString,
801    gutter_width: Pixels,
802    mode: DiffMode,
803    window: &mut Window,
804    cx: &App,
805) -> Pixels {
806    // Glyph widths vary within a font, so shape several candidates rather
807    // than trusting the single widest by cell count.
808    const CANDIDATES: usize = 8;
809    let mut candidates: Vec<(usize, &SharedString)> = Vec::with_capacity(CANDIDATES + 1);
810    for file in files {
811        for side in [DiffSide::Original, DiffSide::Modified] {
812            for line in file.lines(side) {
813                let cells = unicode_width::UnicodeWidthStr::width(line.display().as_str());
814                if candidates.len() == CANDIDATES
815                    && candidates
816                        .last()
817                        .is_some_and(|(widest, _)| *widest >= cells)
818                {
819                    continue;
820                }
821                let ix = candidates.partition_point(|(widest, _)| *widest >= cells);
822                candidates.insert(ix, (cells, line.display()));
823                candidates.truncate(CANDIDATES);
824            }
825        }
826    }
827    let font = gpui::Font {
828        family: family.clone(),
829        ..Default::default()
830    };
831    let width = candidates
832        .into_iter()
833        .map(|(_, text)| {
834            window
835                .text_system()
836                .shape_line(
837                    text.clone(),
838                    font_size,
839                    &[TextRun {
840                        len: text.len(),
841                        font: font.clone(),
842                        color: cx.theme().foreground,
843                        background_color: None,
844                        underline: None,
845                        strikethrough: None,
846                    }],
847                    None,
848                )
849                .width
850        })
851        .fold(px(0.), Pixels::max);
852    code_width(width, gutter_width, window.rem_size(), mode)
853}
854
855fn render_row(
856    row: &DisplayRow,
857    code: Rc<CodePresentation>,
858    window: &mut Window,
859    cx: &mut App,
860) -> AnyElement {
861    match row {
862        DisplayRow::File(file) => render_file_header(*file, &code, window, cx),
863        DisplayRow::Notice(file) => render_notice(&code.files[*file], cx),
864        DisplayRow::Hunk { file, hunk } => render_hunk_separator(*file, *hunk, &code, cx),
865        DisplayRow::Fold { file, pairs } => render_fold(*file, pairs.clone(), &code, cx),
866        DisplayRow::Conflict {
867            file,
868            conflict,
869            part,
870        } => render_conflict(*file, *conflict, *part, &code, cx),
871        DisplayRow::Code {
872            file,
873            original,
874            modified,
875            changed,
876        } => {
877            if code.mode == DiffMode::Split && !code.files[*file].is_single_column() {
878                h_flex()
879                    .items_stretch()
880                    .w_full()
881                    .child(render_cell(
882                        *file,
883                        DiffSide::Original,
884                        *original,
885                        *modified,
886                        *changed,
887                        code.clone(),
888                        window,
889                        cx,
890                    ))
891                    .child(render_cell(
892                        *file,
893                        DiffSide::Modified,
894                        *modified,
895                        *original,
896                        *changed,
897                        code,
898                        window,
899                        cx,
900                    ))
901                    .into_any_element()
902            } else {
903                let (side, ix) = match (original, modified) {
904                    (_, Some(modified)) => (DiffSide::Modified, *modified),
905                    (Some(original), None) => (DiffSide::Original, *original),
906                    (None, None) => unreachable!("a code row shows at least one side"),
907                };
908                render_unified_cell(
909                    *file,
910                    side,
911                    ix,
912                    (*original, *modified),
913                    *changed,
914                    code,
915                    window,
916                    cx,
917                )
918            }
919        }
920    }
921}
922
923fn render_hunk_separator(
924    file: usize,
925    hunk: usize,
926    code: &CodePresentation,
927    cx: &App,
928) -> AnyElement {
929    let hunks = code.files[file].hunks();
930    let row = h_flex()
931        .w_full()
932        .px_3()
933        .bg(cx.theme().muted.opacity(0.35))
934        .text_xs()
935        .text_color(cx.theme().muted_foreground);
936    match code.hunk_separator {
937        DiffHunkSeparator::Metadata => row
938            .h_6()
939            .child(hunks[hunk].label().clone())
940            .into_any_element(),
941        DiffHunkSeparator::LineInfo => {
942            let hidden = hunks[hunk].hidden_lines_before(hunk.checked_sub(1).map(|ix| &hunks[ix]));
943            if hidden == 0 {
944                return div().into_any_element();
945            }
946            row.h_6()
947                .child(t!("Diff.HiddenLines", count = hidden).to_string())
948                .into_any_element()
949        }
950        DiffHunkSeparator::Simple => row.h_1().into_any_element(),
951    }
952}
953
954fn render_fold(
955    file: usize,
956    pairs: std::ops::Range<usize>,
957    code: &CodePresentation,
958    cx: &App,
959) -> AnyElement {
960    let document = &code.files[file];
961    // A fold bordering the hunk start has no source above it, and one
962    // bordering the hunk end has none below; offer only the meaningful sides.
963    let hunk = document
964        .hunks()
965        .iter()
966        .find(|hunk| hunk.pairs().contains(&pairs.start))
967        .map_or(0..document.pairs().len(), |hunk| hunk.pairs());
968    let lines = code.expansion_lines;
969    let partial = pairs.len() > lines;
970    let expand = |id: (&'static str, usize), expansion: FoldExpansion| {
971        let state = code.state.clone();
972        let pairs = pairs.clone();
973        Button::new(id)
974            .ghost()
975            .xsmall()
976            .w_auto()
977            .text_color(cx.theme().muted_foreground)
978            .on_click(move |_, _, cx| {
979                state.update(cx, |state, cx| {
980                    state.expand_fold(file, pairs.clone(), expansion, cx)
981                })
982            })
983    };
984    h_flex()
985        .w_full()
986        .h_6()
987        .px_2()
988        .gap_1()
989        .bg(cx.theme().muted.opacity(0.35))
990        .when(partial && pairs.start > hunk.start, |this| {
991            this.child(
992                expand(("expand-down", pairs.start), FoldExpansion::Down)
993                    .icon(IconName::ArrowDown)
994                    .accessibility_label(t!("Diff.ExpandBelow", count = lines).to_string()),
995            )
996        })
997        .when(partial && pairs.end < hunk.end, |this| {
998            this.child(
999                expand(("expand-up", pairs.start), FoldExpansion::Up)
1000                    .icon(IconName::ArrowUp)
1001                    .accessibility_label(t!("Diff.ExpandAbove", count = lines).to_string()),
1002            )
1003        })
1004        .child(
1005            expand(("expand", pairs.start), FoldExpansion::All)
1006                .icon(if partial {
1007                    IconName::ChevronsUpDown
1008                } else {
1009                    IconName::ChevronDown
1010                })
1011                .label(t!("Diff.UnchangedLines", count = pairs.len()).to_string())
1012                .accessibility_label(t!("Diff.ExpandLines", count = pairs.len()).to_string()),
1013        )
1014        .into_any_element()
1015}
1016
1017/// The heading of one part of a conflict, with the resolution commands on
1018/// the current part; a resolved conflict shows its choice and Undo instead.
1019fn render_conflict(
1020    file: usize,
1021    ix: usize,
1022    part: ConflictPart,
1023    code: &CodePresentation,
1024    cx: &App,
1025) -> AnyElement {
1026    let document = &code.files[file];
1027    let conflict = &document.conflicts()[ix];
1028    let resolution = code.state.read(cx).conflict_resolution(document.path(), ix);
1029    let choose = |id: &'static str, label: String, resolution: Option<DiffConflictResolution>| {
1030        let state = code.state.clone();
1031        Button::new((id, ix))
1032            .ghost()
1033            .xsmall()
1034            .w_auto()
1035            .label(label)
1036            .on_click(move |_, _, cx| {
1037                state.update(cx, |state, cx| {
1038                    state.choose_conflict(file, ix, resolution, cx)
1039                })
1040            })
1041    };
1042    let row = h_flex()
1043        .w_full()
1044        .h_6()
1045        .px_3()
1046        .gap_2()
1047        .text_xs()
1048        .text_color(cx.theme().muted_foreground)
1049        .bg(conflict_tint(Some(part), cx).opacity(0.25));
1050    if let Some(resolution) = resolution {
1051        let label = match resolution {
1052            DiffConflictResolution::Current => t!("Diff.AcceptedCurrent"),
1053            DiffConflictResolution::Incoming => t!("Diff.AcceptedIncoming"),
1054            _ => t!("Diff.AcceptedBoth"),
1055        };
1056        return row
1057            .bg(cx.theme().muted.opacity(0.35))
1058            .child(label.to_string())
1059            .child(choose(
1060                "undo-resolution",
1061                t!("Diff.UndoResolution").to_string(),
1062                None,
1063            ))
1064            .into_any_element();
1065    }
1066    let title = match part {
1067        ConflictPart::Current => t!("Diff.CurrentChange"),
1068        ConflictPart::Base => t!("Diff.BaseChange"),
1069        ConflictPart::Incoming => t!("Diff.IncomingChange"),
1070    };
1071    let label = conflict.label(part).clone();
1072    row.child(div().font_medium().child(title.to_string()))
1073        .when(!label.is_empty(), |this| this.child(label))
1074        .when(part == ConflictPart::Current, |this| {
1075            this.child(div().flex_1())
1076                .child(choose(
1077                    "accept-current",
1078                    t!("Diff.AcceptCurrent").to_string(),
1079                    Some(DiffConflictResolution::Current),
1080                ))
1081                .child(choose(
1082                    "accept-incoming",
1083                    t!("Diff.AcceptIncoming").to_string(),
1084                    Some(DiffConflictResolution::Incoming),
1085                ))
1086                .child(choose(
1087                    "accept-both",
1088                    t!("Diff.AcceptBoth").to_string(),
1089                    Some(DiffConflictResolution::Both),
1090                ))
1091        })
1092        .into_any_element()
1093}
1094
1095/// The color that marks lines of a conflict part.
1096fn conflict_tint(part: Option<ConflictPart>, cx: &App) -> Hsla {
1097    match part {
1098        Some(ConflictPart::Current) => cx.theme().success,
1099        Some(ConflictPart::Incoming) => cx.theme().info,
1100        _ => cx.theme().muted_foreground,
1101    }
1102}
1103
1104fn render_file_header(
1105    file: usize,
1106    code: &CodePresentation,
1107    window: &mut Window,
1108    cx: &mut App,
1109) -> AnyElement {
1110    if !code.header_visible {
1111        return div()
1112            .children(file_annotations(file, code, window, cx))
1113            .into_any_element();
1114    }
1115    let document = &code.files[file];
1116    let foreground = cx.theme().muted_foreground;
1117    let border = cx.theme().border;
1118    let collapsed = code.state.read(cx).is_file_collapsed(document.path());
1119    let content = if let Some(render) = &code.header.content {
1120        render(document, window, cx)
1121    } else {
1122        let name: SharedString = match (document.original_path(), document.modified_path()) {
1123            (Some(old), Some(new)) if old != new => format!("{old} → {new}").into(),
1124            _ => document.path().clone(),
1125        };
1126        let slot = |render: &Option<FileRenderer>, window: &mut Window, cx: &mut App| {
1127            render.as_ref().map(|render| render(document, window, cx))
1128        };
1129        let prefix = slot(&code.header.prefix, window, cx);
1130        let title_suffix = slot(&code.header.title_suffix, window, cx);
1131        let suffix = slot(&code.header.suffix, window, cx);
1132        let status = match document.status() {
1133            DiffFileStatus::Added => Some(t!("Diff.AddedFile")),
1134            DiffFileStatus::Deleted => Some(t!("Diff.DeletedFile")),
1135            DiffFileStatus::Renamed => Some(t!("Diff.RenamedFile")),
1136            DiffFileStatus::Copied => Some(t!("Diff.CopiedFile")),
1137            DiffFileStatus::Conflicted => Some(t!("Diff.ConflictedFile")),
1138            _ => None,
1139        };
1140        let statistics = !document.is_single_column();
1141        h_flex()
1142            .w_full()
1143            .min_w_0()
1144            .gap_2()
1145            .children(prefix)
1146            .child(
1147                h_flex()
1148                    .min_w_0()
1149                    .flex_1()
1150                    .gap_2()
1151                    .child(div().min_w_0().truncate().font_medium().child(name))
1152                    .when_some(title_suffix, |this, suffix| {
1153                        this.child(div().flex_shrink_0().child(suffix))
1154                    }),
1155            )
1156            .when(statistics, |this| {
1157                this.child(div().text_xs().text_color(foreground).child(format!(
1158                    "+{} −{}",
1159                    document.additions(),
1160                    document.deletions()
1161                )))
1162            })
1163            .when_some(status, |this, status| {
1164                this.child(
1165                    div()
1166                        .text_xs()
1167                        .text_color(foreground)
1168                        .child(status.to_string()),
1169                )
1170            })
1171            .children(suffix)
1172            .into_any_element()
1173    };
1174    let state = code.state.clone();
1175    let name = document.path().clone();
1176    v_flex()
1177        .id("file-header")
1178        .w_full()
1179        .when(file > 0, |this| this.border_t_1())
1180        .border_b_1()
1181        .border_color(border)
1182        .child(
1183            h_flex()
1184                .w_full()
1185                .min_w_0()
1186                .pl_1()
1187                .pr_3()
1188                .py_1()
1189                .gap_1()
1190                .child(
1191                    Button::new("collapse-file")
1192                        .ghost()
1193                        .xsmall()
1194                        .icon(if collapsed {
1195                            IconName::ChevronRight
1196                        } else {
1197                            IconName::ChevronDown
1198                        })
1199                        .accessibility_label(
1200                            if collapsed {
1201                                t!("Diff.ExpandFile", name = name.as_str())
1202                            } else {
1203                                t!("Diff.CollapseFile", name = name.as_str())
1204                            }
1205                            .to_string(),
1206                        )
1207                        .on_click(move |_, _, cx| {
1208                            state.update(cx, |state, cx| state.toggle_file_collapsed(file, cx))
1209                        }),
1210                )
1211                .child(div().flex_1().min_w_0().child(content)),
1212        )
1213        .children(file_annotations(file, code, window, cx))
1214        .into_any_element()
1215}
1216
1217fn file_annotations(
1218    file: usize,
1219    code: &CodePresentation,
1220    window: &mut Window,
1221    cx: &mut App,
1222) -> Vec<AnyElement> {
1223    let path = code.files[file].path();
1224    let ixs = code
1225        .annotations
1226        .files
1227        .get(path)
1228        .cloned()
1229        .unwrap_or_default();
1230    annotation_elements(&ixs, code, window, cx)
1231}
1232
1233/// Summarizes a file without source rows: binary, metadata-only or empty.
1234fn render_notice(file: &DiffFile, cx: &App) -> AnyElement {
1235    let message = if !file.has_changes() {
1236        t!("Diff.NoChanges")
1237    } else if file.is_binary() {
1238        t!("Diff.BinaryChanges")
1239    } else if !file.extended_headers().is_empty() {
1240        t!("Diff.NoTextChanges")
1241    } else {
1242        t!("Diff.EmptyFile")
1243    };
1244    v_flex()
1245        .w_full()
1246        .px_3()
1247        .py_2()
1248        .gap_1()
1249        .text_xs()
1250        .text_color(cx.theme().muted_foreground)
1251        .children(
1252            file.extended_headers()
1253                .iter()
1254                .cloned()
1255                .map(|line| div().child(line)),
1256        )
1257        .child(div().text_sm().child(message.to_string()))
1258        .into_any_element()
1259}
1260
1261fn render_gutter(
1262    file: usize,
1263    side: DiffSide,
1264    ix: Option<usize>,
1265    code: &CodePresentation,
1266    cx: &App,
1267) -> AnyElement {
1268    let hover = matches!(
1269        code.hover_highlight,
1270        DiffHoverHighlight::LineNumber | DiffHoverHighlight::Both
1271    );
1272    div()
1273        .id(if side == DiffSide::Original {
1274            "old-gutter"
1275        } else {
1276            "new-gutter"
1277        })
1278        .flex_shrink_0()
1279        .w(code.gutter_width)
1280        .pr_1()
1281        .when(hover, |this| {
1282            this.hover(|this| this.bg(cx.theme().list_hover))
1283        })
1284        .when_some(ix, |this, ix| {
1285            let position = code.position(file, side, ix);
1286            let down = code.state.clone();
1287            let moved = code.state.clone();
1288            let dragged = position.clone();
1289            // A plain hit target: line numbers need no button chrome or state.
1290            this.child(
1291                div()
1292                    .id(("line", ix))
1293                    .test_support()
1294                    .w_full()
1295                    .h(code.row_height)
1296                    .px_1()
1297                    .flex()
1298                    .items_center()
1299                    .justify_end()
1300                    .text_size(code.font_size)
1301                    .text_color(cx.theme().muted_foreground)
1302                    .hover(|this| this.text_color(cx.theme().foreground))
1303                    .role(gpui::accesskit::Role::Button)
1304                    .aria_label(
1305                        t!(
1306                            "Diff.SelectLine",
1307                            side = side_label(side),
1308                            line = position.line()
1309                        )
1310                        .to_string(),
1311                    )
1312                    .child(position.line().to_string())
1313                    .on_mouse_down(MouseButton::Left, move |event, window, cx| {
1314                        down.update(cx, |state, cx| {
1315                            state.begin_line_selection(
1316                                position.clone(),
1317                                event.modifiers.shift,
1318                                window,
1319                                cx,
1320                            )
1321                        })
1322                    })
1323                    .on_mouse_move(move |event, _, cx| {
1324                        if event.pressed_button == Some(MouseButton::Left) {
1325                            moved.update(cx, |state, cx| {
1326                                state.drag_line_selection(dragged.clone(), cx)
1327                            })
1328                        }
1329                    }),
1330            )
1331        })
1332        .into_any_element()
1333}
1334
1335fn side_label(side: DiffSide) -> std::borrow::Cow<'static, str> {
1336    if side == DiffSide::Original {
1337        t!("Diff.Original")
1338    } else {
1339        t!("Diff.Modified")
1340    }
1341}
1342
1343#[allow(clippy::too_many_arguments)]
1344fn render_cell(
1345    file: usize,
1346    side: DiffSide,
1347    ix: Option<usize>,
1348    other_ix: Option<usize>,
1349    changed: bool,
1350    code: Rc<CodePresentation>,
1351    window: &mut Window,
1352    cx: &mut App,
1353) -> AnyElement {
1354    let cell = v_flex()
1355        .w(code.column_width)
1356        .min_w_0()
1357        .flex_shrink_0()
1358        .when(side == DiffSide::Original, |this| {
1359            this.border_r_1().border_color(cx.theme().border)
1360        })
1361        .when(ix.is_none(), |this| {
1362            this.bg(cx.theme().muted.opacity(0.3))
1363                .child(div().h(code.row_height))
1364        });
1365    let Some(ix) = ix else {
1366        return cell.into_any_element();
1367    };
1368    cell.id((
1369        if side == DiffSide::Original {
1370            "old"
1371        } else {
1372            "new"
1373        },
1374        ix,
1375    ))
1376    .child(code_line(
1377        file,
1378        side,
1379        ix,
1380        changed,
1381        code.clone(),
1382        [Some((side, Some(ix))), None],
1383        cx,
1384    ))
1385    .children(line_extras(
1386        file, side, ix, other_ix, changed, &code, window, cx,
1387    ))
1388    .into_any_element()
1389}
1390
1391#[allow(clippy::too_many_arguments)]
1392fn render_unified_cell(
1393    file: usize,
1394    side: DiffSide,
1395    ix: usize,
1396    pair: (Option<usize>, Option<usize>),
1397    changed: bool,
1398    code: Rc<CodePresentation>,
1399    window: &mut Window,
1400    cx: &mut App,
1401) -> AnyElement {
1402    let (original, modified) = pair;
1403    let single_column = code.files[file].is_single_column();
1404    let counterpart = code.files[file].lines(side)[ix].counterpart();
1405    let other_ix = if side == DiffSide::Original {
1406        modified
1407    } else {
1408        original
1409    };
1410    let mut extras = line_extras(file, side, ix, counterpart, changed, &code, window, cx);
1411    if !changed && let Some(other_ix) = other_ix {
1412        extras.extend(line_annotations(
1413            file,
1414            side.other(),
1415            other_ix,
1416            &code,
1417            window,
1418            cx,
1419        ));
1420    }
1421    let gutters = if single_column {
1422        [Some((DiffSide::Modified, modified)), None]
1423    } else {
1424        [
1425            Some((DiffSide::Original, original)),
1426            Some((DiffSide::Modified, modified)),
1427        ]
1428    };
1429    v_flex()
1430        .w(if single_column {
1431            code.content_width
1432        } else {
1433            code.column_width
1434        })
1435        .id((
1436            if side == DiffSide::Original {
1437                "old"
1438            } else {
1439                "new"
1440            },
1441            ix,
1442        ))
1443        .child(code_line(
1444            file,
1445            side,
1446            ix,
1447            changed,
1448            code.clone(),
1449            gutters,
1450            cx,
1451        ))
1452        .children(extras)
1453        .into_any_element()
1454}
1455
1456/// Syntax colors with inline-change and selection emphasis layered over them.
1457#[allow(clippy::too_many_arguments)]
1458fn line_highlights(
1459    file: usize,
1460    side: DiffSide,
1461    ix: usize,
1462    changed: bool,
1463    selected: bool,
1464    status: Hsla,
1465    code: &CodePresentation,
1466    cx: &App,
1467) -> Vec<(std::ops::Range<usize>, HighlightStyle)> {
1468    let line = &code.files[file].lines(side)[ix];
1469    let presentation = code.presentations.get(file).and_then(Option::as_ref);
1470    let mut highlights = match presentation {
1471        Some(presentation) if code.syntax_highlight => {
1472            presentation.syntax_highlights(line, side, ix, cx.theme().highlight_theme.as_ref())
1473        }
1474        _ => Vec::new(),
1475    };
1476    if changed && let Some(presentation) = presentation {
1477        highlights.extend(presentation.inline_changes(line, side, ix).map(|range| {
1478            (
1479                range,
1480                HighlightStyle {
1481                    background_color: Some(status.opacity(0.3)),
1482                    ..Default::default()
1483                },
1484            )
1485        }));
1486    }
1487    if selected {
1488        highlights.push((
1489            0..line.display().len(),
1490            HighlightStyle {
1491                background_color: Some(cx.theme().selection),
1492                ..Default::default()
1493            },
1494        ));
1495    }
1496    highlights
1497}
1498
1499/// The add-annotation button, shown on hover and at the end of the selection.
1500fn add_annotation_button(
1501    file: usize,
1502    side: DiffSide,
1503    ix: usize,
1504    left: Pixels,
1505    code: &CodePresentation,
1506    cx: &App,
1507) -> Option<AnyElement> {
1508    let handler = code.on_add_annotation.clone()?;
1509    let position = code.position(file, side, ix);
1510    let range = code
1511        .selected_lines
1512        .clone()
1513        .filter(|_| code.is_selected(file, side, ix))
1514        .unwrap_or_else(|| {
1515            DiffLineRange::new(
1516                position.path().clone(),
1517                side,
1518                position.line(),
1519                position.line(),
1520            )
1521        });
1522    let at_selection_end = code
1523        .selected
1524        .as_ref()
1525        .is_some_and(|selected| selected.is_last(&code.files, file, side, ix));
1526    Some(
1527        div()
1528            .id(("add-annotation", ix))
1529            .test_support()
1530            .absolute()
1531            .top_0()
1532            .left(left)
1533            .size(code.row_height)
1534            .flex()
1535            .items_center()
1536            .justify_center()
1537            .rounded(cx.theme().radius)
1538            .bg(cx.theme().primary)
1539            .text_color(cx.theme().primary_foreground)
1540            .when(!at_selection_end, |this| {
1541                this.opacity(0.)
1542                    .group_hover("diff-line", |this| this.opacity(1.))
1543            })
1544            .role(gpui::accesskit::Role::Button)
1545            .aria_label(t!("Diff.AddAnnotation", line = position.line()).to_string())
1546            .child(Icon::new(IconName::Plus).xsmall())
1547            .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
1548            .on_click(move |_, window, cx| handler(&range, window, cx))
1549            .into_any_element(),
1550    )
1551}
1552
1553fn code_line(
1554    file: usize,
1555    side: DiffSide,
1556    ix: usize,
1557    changed: bool,
1558    code: Rc<CodePresentation>,
1559    gutters: [Option<(DiffSide, Option<usize>)>; 2],
1560    cx: &mut App,
1561) -> AnyElement {
1562    let document = &code.files[file];
1563    let line = &document.lines(side)[ix];
1564    let conflict_part = (document.status() == DiffFileStatus::Conflicted && changed)
1565        .then(|| {
1566            document
1567                .conflicts()
1568                .iter()
1569                .find(|conflict| conflict.source_lines().contains(&ix))
1570                .and_then(|conflict| conflict.part_of(ix))
1571        })
1572        .flatten();
1573    let status = if conflict_part.is_some() {
1574        conflict_tint(conflict_part, cx)
1575    } else if side == DiffSide::Original {
1576        cx.theme().danger
1577    } else {
1578        cx.theme().success
1579    };
1580    let selected = code.is_selected(file, side, ix)
1581        || (code.mode == DiffMode::Unified
1582            && !changed
1583            && gutters
1584                .iter()
1585                .flatten()
1586                .any(|(side, ix)| ix.is_some_and(|ix| code.is_selected(file, *side, ix))));
1587    let highlights = line_highlights(file, side, ix, changed, selected, status, &code, cx);
1588    // An unchanged Unified row shows one source line for both sides; follow
1589    // the side a text selection started on so its offsets stay consistent.
1590    let (text_side, text_ix) = if code.mode == DiffMode::Unified
1591        && !changed
1592        && let Some((selected_file, selected_side, _)) =
1593            selection::selected_source_range(&code.selection, cx)
1594        && selected_file == file
1595        && selected_side != side
1596        && let Some(other_ix) = line.counterpart()
1597    {
1598        (selected_side, other_ix)
1599    } else {
1600        (side, ix)
1601    };
1602    let position = code.position(file, text_side, text_ix);
1603    let source_line = &document.lines(text_side)[text_ix];
1604    // Wrapped text must be one run to break across the column; unwrapped
1605    // long lines stay chunked to bound shaping and selection projection.
1606    let ranges: Vec<_> = if code.soft_wrap {
1607        vec![0..source_line.display().len()]
1608    } else {
1609        source_line.chunk_ranges().collect()
1610    };
1611    // A block container gives wrapped text a definite width to break at.
1612    let text = div()
1613        .map(|this| {
1614            if code.soft_wrap {
1615                this.w_full()
1616            } else {
1617                this.flex().flex_row().flex_shrink_0()
1618            }
1619        })
1620        .children(ranges.into_iter().enumerate().map(|(chunk_ix, range)| {
1621            CodeText::new(
1622                ("code", chunk_ix).into(),
1623                document.clone(),
1624                file,
1625                text_side,
1626                text_ix,
1627                range,
1628                highlights.clone(),
1629                code.selection.clone(),
1630                code.geometry.clone(),
1631            )
1632        }));
1633    let gutters_width = if code.line_number {
1634        code.gutter_width * gutters.iter().flatten().count() as f32
1635    } else {
1636        px(0.)
1637    };
1638    let indicator = match code.change_indicator {
1639        DiffChangeIndicator::Signs => div()
1640            .w_4()
1641            .flex_shrink_0()
1642            .text_color(if changed {
1643                status
1644            } else {
1645                cx.theme().muted_foreground
1646            })
1647            .child(match (changed && conflict_part.is_none(), side) {
1648                (false, _) => " ",
1649                (true, DiffSide::Original) => "−",
1650                (true, DiffSide::Modified) => "+",
1651            }),
1652        DiffChangeIndicator::Bars => div().w_4().h_full().flex_shrink_0().when(changed, |this| {
1653            this.child(div().w(px(3.)).h_full().bg(status))
1654        }),
1655        DiffChangeIndicator::None => div().w_2().flex_shrink_0(),
1656    };
1657    let hover_line = matches!(
1658        code.hover_highlight,
1659        DiffHoverHighlight::Line | DiffHoverHighlight::Both
1660    );
1661    let add_button =
1662        add_annotation_button(file, side, ix, gutters_width + code.rem / 2., &code, cx);
1663    h_flex()
1664        .id("line-row")
1665        .group("diff-line")
1666        .relative()
1667        .w_full()
1668        .px_2()
1669        .map(|this| {
1670            if code.soft_wrap {
1671                this.min_h(code.row_height)
1672                    .items_start()
1673                    .whitespace_normal()
1674            } else {
1675                this.h(code.row_height).whitespace_nowrap()
1676            }
1677        })
1678        .font_family(cx.theme().mono_font_family.clone())
1679        .text_size(code.font_size)
1680        .line_height(code.row_height)
1681        .when(changed && code.change_background, |this| {
1682            this.bg(status.opacity(0.12))
1683        })
1684        .when(selected, |this| this.bg(cx.theme().selection))
1685        .when(hover_line && !selected, |this| {
1686            this.hover(|this| this.bg(cx.theme().list_hover))
1687        })
1688        .when_some(code.on_line_hover.clone(), |this, handler| {
1689            let position = position.clone();
1690            this.on_hover(move |hovered, window, cx| {
1691                handler(hovered.then_some(&position), window, cx)
1692            })
1693        })
1694        .when(code.line_number, |this| {
1695            this.children(
1696                gutters
1697                    .into_iter()
1698                    .flatten()
1699                    .map(|(side, ix)| render_gutter(file, side, ix, &code, cx)),
1700            )
1701        })
1702        .child(indicator)
1703        .child(
1704            div()
1705                .id("source")
1706                .test_support()
1707                .map(|this| {
1708                    if code.soft_wrap {
1709                        this.flex_1().min_w_0()
1710                    } else {
1711                        this.flex_shrink_0()
1712                    }
1713                })
1714                .role(gpui::accesskit::Role::Label)
1715                .aria_label(source_line.text().clone())
1716                .aria_description(
1717                    t!(
1718                        if changed {
1719                            if side == DiffSide::Original {
1720                                "Diff.RemovedLine"
1721                            } else {
1722                                "Diff.AddedLine"
1723                            }
1724                        } else {
1725                            "Diff.SourceLine"
1726                        },
1727                        side = side_label(text_side),
1728                        line = position.line()
1729                    )
1730                    .to_string(),
1731                )
1732                .when_some(code.on_line_click.clone(), |this, handler| {
1733                    let position = position.clone();
1734                    this.on_click(move |event, window, cx| {
1735                        // A drag selects text; only a stationary press is a click.
1736                        if let ClickEvent::Mouse(mouse) = event {
1737                            let moved = mouse.up.position - mouse.down.position;
1738                            if moved.x.abs() > px(3.) || moved.y.abs() > px(3.) {
1739                                return;
1740                            }
1741                        }
1742                        handler(&position, event, window, cx)
1743                    })
1744                })
1745                .child(text),
1746        )
1747        .children(add_button)
1748        .into_any_element()
1749}
1750
1751#[allow(clippy::too_many_arguments)]
1752fn line_extras(
1753    file: usize,
1754    side: DiffSide,
1755    ix: usize,
1756    other_ix: Option<usize>,
1757    changed: bool,
1758    code: &CodePresentation,
1759    window: &mut Window,
1760    cx: &mut App,
1761) -> Vec<AnyElement> {
1762    let mut extras = Vec::new();
1763    let document = &code.files[file];
1764    let line = &document.lines(side)[ix];
1765    let ending = &document.source(side)[line.content_end()..line.source().end];
1766    let ending_only = changed && has_line_ending_change(document, side, ix, other_ix);
1767    if ending.is_empty() || ending_only {
1768        let label = if ending.is_empty() {
1769            t!("Diff.NoNewline").to_string()
1770        } else {
1771            format!("\\ {}", if ending == "\r\n" { "CRLF" } else { "LF" })
1772        };
1773        extras.push(
1774            div()
1775                .h_6()
1776                .px_3()
1777                .text_xs()
1778                .text_color(cx.theme().muted_foreground)
1779                .child(label)
1780                .into_any_element(),
1781        );
1782    }
1783    extras.extend(line_annotations(file, side, ix, code, window, cx));
1784    extras
1785}
1786
1787fn line_annotations(
1788    file: usize,
1789    side: DiffSide,
1790    ix: usize,
1791    code: &CodePresentation,
1792    window: &mut Window,
1793    cx: &mut App,
1794) -> Vec<AnyElement> {
1795    let position = code.position(file, side, ix);
1796    let ixs = code
1797        .annotations
1798        .lines
1799        .get(&position)
1800        .cloned()
1801        .unwrap_or_default();
1802    annotation_elements(&ixs, code, window, cx)
1803}
1804
1805fn annotation_elements(
1806    ixs: &[usize],
1807    code: &CodePresentation,
1808    window: &mut Window,
1809    cx: &mut App,
1810) -> Vec<AnyElement> {
1811    let Some(render) = &code.annotation_content else {
1812        return Vec::new();
1813    };
1814    ixs.iter()
1815        .map(|ix| {
1816            let annotation = &code.annotations.items[*ix];
1817            div()
1818                .id(annotation.id.clone())
1819                .w_full()
1820                .px_3()
1821                .py_3()
1822                .border_t_1()
1823                .border_b_1()
1824                .border_color(cx.theme().border)
1825                .bg(cx.theme().muted.opacity(0.18))
1826                .child(render(annotation, window, cx))
1827                .into_any_element()
1828        })
1829        .collect()
1830}
1831
1832fn has_line_ending_change(
1833    file: &DiffFile,
1834    side: DiffSide,
1835    ix: usize,
1836    counterpart: Option<usize>,
1837) -> bool {
1838    counterpart.is_some_and(|counterpart| {
1839        let other_side = side.other();
1840        let line = &file.lines(side)[ix];
1841        let other = &file.lines(other_side)[counterpart];
1842        line.text() == other.text()
1843            && file.source(side)[line.content_end()..line.source().end]
1844                != file.source(other_side)[other.content_end()..other.source().end]
1845    })
1846}
1847
1848#[cfg(test)]
1849mod copy_tests;
1850#[cfg(test)]
1851mod header_tests;
1852#[cfg(test)]
1853mod newline_tests;
1854#[cfg(test)]
1855mod review_tests;
1856#[cfg(test)]
1857mod selection_tests;
1858#[cfg(test)]
1859mod tests;