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