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