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