Skip to main content

gpui_component/diff/
mod.rs

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