1mod 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
45mod 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum DiffMode {
85 Split,
87 #[default]
89 Unified,
90}
91
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum DiffHoverHighlight {
96 #[default]
97 None,
98 Line,
100 LineNumber,
102 Both,
104}
105
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum DiffHunkSeparator {
110 #[default]
112 Metadata,
113 LineInfo,
115 Simple,
117}
118
119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
121#[non_exhaustive]
122pub enum DiffChangeIndicator {
123 #[default]
125 Signs,
126 Bars,
128 None,
130}
131
132#[derive(Clone)]
138pub struct DiffAnnotation {
139 id: ElementId,
140 path: SharedString,
141 position: Option<DiffLinePosition>,
142}
143
144impl DiffAnnotation {
145 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 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 pub fn path(&self) -> &SharedString {
166 &self.path
167 }
168 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#[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 pub fn line_number(mut self, value: bool) -> Self {
253 self.line_number = value;
254 self
255 }
256 pub fn syntax_highlight(mut self, value: bool) -> Self {
258 self.syntax_highlight = value;
259 self
260 }
261 pub fn header_visible(mut self, visible: bool) -> Self {
263 self.header_visible = visible;
264 self
265 }
266 pub fn hover_highlight(mut self, highlight: DiffHoverHighlight) -> Self {
268 self.hover_highlight = highlight;
269 self
270 }
271 pub fn hunk_separator(mut self, separator: DiffHunkSeparator) -> Self {
273 self.hunk_separator = separator;
274 self
275 }
276 pub fn change_indicator(mut self, indicator: DiffChangeIndicator) -> Self {
278 self.change_indicator = indicator;
279 self
280 }
281 pub fn change_background(mut self, value: bool) -> Self {
283 self.change_background = value;
284 self
285 }
286 pub fn soft_wrap(mut self, value: bool) -> Self {
289 self.soft_wrap = value;
290 self
291 }
292 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 .when(!soft_wrap, |this| this.overflow_x_hidden())
556 .track_scroll(&horizontal)
557 .child(
558 list(scrollbar.clone(), move |ix, window, cx| {
559 let row = &rows[ix];
560 div()
561 .id(("diff-file", row.file()))
562 .w_full()
563 .child(render_row(row, code.clone(), window, cx))
564 .into_any_element()
565 })
566 .w(content_width)
567 .h_full(),
568 )
569 .into_any_element(),
570 scrollbar.clone(),
571 horizontal.clone(),
572 state.selection().clone(),
573 state.geometry().clone(),
574 ))
575 .when(has_rows && !soft_wrap, |this| {
576 this.child(
577 ScrollableMask::new(Axis::Horizontal, &horizontal).id("diff-horizontal-wheel"),
578 )
579 })
580 .when(has_rows, |this| {
581 this.child(Scrollbar::vertical(&scrollbar))
582 .when(!soft_wrap, |this| {
583 this.child(Scrollbar::horizontal(&horizontal))
584 })
585 })
586 .on_mouse_up(
588 MouseButton::Left,
589 cx.listener(|this, _, _, cx| this.end_line_selection(cx)),
590 )
591 .on_mouse_up_out(
592 MouseButton::Left,
593 cx.listener(|this, _, _, cx| this.end_line_selection(cx)),
594 )
595 .on_action(cx.listener(|this, _: &ScrollUp, window, cx| {
596 if !body_focused(this, window, cx) {
597 return;
598 }
599 this.list().scroll_by(-window.rem_size() * 1.5);
600 cx.notify();
601 }))
602 .on_action(cx.listener(|this, _: &ScrollDown, window, cx| {
603 if !body_focused(this, window, cx) {
604 return;
605 }
606 this.list().scroll_by(window.rem_size() * 1.5);
607 cx.notify();
608 }))
609 .on_action(cx.listener(|this, _: &ScrollLeft, window, cx| {
610 if !body_focused(this, window, cx) {
611 return;
612 }
613 let offset = this.horizontal_scroll().offset();
614 this.horizontal_scroll()
615 .set_offset(point(offset.x + window.rem_size() * 3., offset.y));
616 cx.notify();
617 }))
618 .on_action(cx.listener(|this, _: &ScrollRight, window, cx| {
619 if !body_focused(this, window, cx) {
620 return;
621 }
622 let offset = this.horizontal_scroll().offset();
623 this.horizontal_scroll()
624 .set_offset(point(offset.x - window.rem_size() * 3., offset.y));
625 cx.notify();
626 }))
627 .on_action(cx.listener(|this, _: &PageUp, window, cx| {
628 if !body_focused(this, window, cx) {
629 return;
630 }
631 this.list().scroll_by(-this.viewport().height * 0.9);
632 cx.notify();
633 }))
634 .on_action(cx.listener(|this, _: &PageDown, window, cx| {
635 if !body_focused(this, window, cx) {
636 return;
637 }
638 this.list().scroll_by(this.viewport().height * 0.9);
639 cx.notify();
640 }))
641 .on_action(cx.listener(|this, _: &First, window, cx| {
642 if !body_focused(this, window, cx) {
643 return;
644 }
645 this.list().scroll_to(ListOffset::default());
646 cx.notify();
647 }))
648 .on_action(cx.listener(|this, _: &Last, window, cx| {
649 if !body_focused(this, window, cx) {
650 return;
651 }
652 this.list().scroll_to_end();
653 cx.notify();
654 }))
655 .on_action(cx.listener(|this, _: &ExtendUp, window, cx| {
656 if !body_focused(this, window, cx) {
657 return;
658 }
659 this.keyboard_select(-1, true, window, cx)
660 }))
661 .on_action(cx.listener(|this, _: &ExtendDown, window, cx| {
662 if !body_focused(this, window, cx) {
663 return;
664 }
665 this.keyboard_select(1, true, window, cx)
666 }))
667 .on_action(cx.listener(|this, _: &SelectAll, window, cx| {
668 if body_focused(this, window, cx) {
669 this.select_all(window, cx);
670 }
671 }))
672 .on_action(cx.listener(|this, _: &Copy, window, cx| {
673 if !body_focused(this, window, cx) {
674 return;
675 }
676 let text = this.selected_text(cx);
677 if text.is_empty() {
678 cx.propagate();
679 } else {
680 cx.write_to_clipboard(ClipboardItem::new_string(text));
681 }
682 }))
683 .child(
684 gpui::canvas(
685 {
686 let state = cx.entity();
687 move |bounds, _, cx| {
688 state.update(cx, |state, cx| {
689 let painted = if soft_wrap {
692 px(0.)
693 } else {
694 let painted = state.geometry().borrow().painted_width();
695 code_width(painted, gutter_width, rem, mode)
696 };
697 if state.record_paint(bounds.size, painted) {
698 cx.notify();
699 }
700 });
701 }
702 },
703 |_, _, _, _| {},
704 )
705 .absolute()
706 .size_full(),
707 )
708 .test_support()
709 .track_focus(&state.focus_handle(cx))
710 .key_context("Diff")
711 .role(gpui::accesskit::Role::Document)
712 .aria_label(label);
713 v_flex()
714 .id(("diff", cx.entity_id()))
715 .size_full()
716 .min_w_0()
717 .min_h_0()
718 .bg(cx.theme().background)
719 .text_color(cx.theme().foreground)
720 .text_sm()
721 .border_1()
722 .border_color(cx.theme().border)
723 .child(body)
724 .refine_style(&props.style)
725 .into_any_element()
726}
727
728fn body_focused(state: &DiffState, window: &Window, cx: &mut Context<DiffState>) -> bool {
729 if state.focus_handle(cx).is_focused(window) {
730 true
731 } else {
732 cx.propagate();
733 false
734 }
735}
736
737struct CodePresentation {
738 files: Vec<DiffFile>,
739 presentations: Vec<Option<Arc<FilePresentation>>>,
740 mode: DiffMode,
741 content_width: Pixels,
742 column_width: Pixels,
743 font_size: Pixels,
744 row_height: Pixels,
745 gutter_width: Pixels,
746 rem: Pixels,
747 expansion_lines: usize,
748 line_number: bool,
749 syntax_highlight: bool,
750 header_visible: bool,
751 hover_highlight: DiffHoverHighlight,
752 hunk_separator: DiffHunkSeparator,
753 change_indicator: DiffChangeIndicator,
754 change_background: bool,
755 soft_wrap: bool,
756 header: HeaderSlots,
757 annotations: Rc<Annotations>,
758 annotation_content: Option<AnnotationRenderer>,
759 on_add_annotation: Option<RangeHandler>,
760 on_line_click: Option<ClickHandler>,
761 on_line_hover: Option<HoverHandler>,
762 selection: gpui_base::TextSelectionHandle,
763 selected_lines: Option<DiffLineRange>,
764 selected: Option<SelectedLines>,
765 geometry: Rc<std::cell::RefCell<selection::SelectionGeometry>>,
766 state: Entity<DiffState>,
767}
768
769impl CodePresentation {
770 fn position(&self, file: usize, side: DiffSide, ix: usize) -> DiffLinePosition {
771 let document = &self.files[file];
772 DiffLinePosition::new(
773 document.path().clone(),
774 side,
775 document.line_number(side, ix),
776 )
777 }
778
779 fn is_selected(&self, file: usize, side: DiffSide, ix: usize) -> bool {
780 self.selected
781 .as_ref()
782 .is_some_and(|selected| selected.contains(&self.files, file, side, ix))
783 }
784}
785
786fn code_width(width: Pixels, gutter_width: Pixels, rem: Pixels, mode: DiffMode) -> Pixels {
788 if mode == DiffMode::Split {
789 (width + gutter_width + rem * 2.) * 2.
790 } else {
791 width + gutter_width * 2. + rem * 2.
792 }
793}
794
795fn measure_code_width(
798 files: &[DiffFile],
799 font_size: Pixels,
800 family: &SharedString,
801 gutter_width: Pixels,
802 mode: DiffMode,
803 window: &mut Window,
804 cx: &App,
805) -> Pixels {
806 const CANDIDATES: usize = 8;
809 let mut candidates: Vec<(usize, &SharedString)> = Vec::with_capacity(CANDIDATES + 1);
810 for file in files {
811 for side in [DiffSide::Original, DiffSide::Modified] {
812 for line in file.lines(side) {
813 let cells = unicode_width::UnicodeWidthStr::width(line.display().as_str());
814 if candidates.len() == CANDIDATES
815 && candidates
816 .last()
817 .is_some_and(|(widest, _)| *widest >= cells)
818 {
819 continue;
820 }
821 let ix = candidates.partition_point(|(widest, _)| *widest >= cells);
822 candidates.insert(ix, (cells, line.display()));
823 candidates.truncate(CANDIDATES);
824 }
825 }
826 }
827 let font = gpui::Font {
828 family: family.clone(),
829 ..Default::default()
830 };
831 let width = candidates
832 .into_iter()
833 .map(|(_, text)| {
834 window
835 .text_system()
836 .shape_line(
837 text.clone(),
838 font_size,
839 &[TextRun {
840 len: text.len(),
841 font: font.clone(),
842 color: cx.theme().foreground,
843 background_color: None,
844 underline: None,
845 strikethrough: None,
846 }],
847 None,
848 )
849 .width
850 })
851 .fold(px(0.), Pixels::max);
852 code_width(width, gutter_width, window.rem_size(), mode)
853}
854
855fn render_row(
856 row: &DisplayRow,
857 code: Rc<CodePresentation>,
858 window: &mut Window,
859 cx: &mut App,
860) -> AnyElement {
861 match row {
862 DisplayRow::File(file) => render_file_header(*file, &code, window, cx),
863 DisplayRow::Notice(file) => render_notice(&code.files[*file], cx),
864 DisplayRow::Hunk { file, hunk } => render_hunk_separator(*file, *hunk, &code, cx),
865 DisplayRow::Fold { file, pairs } => render_fold(*file, pairs.clone(), &code, cx),
866 DisplayRow::Conflict {
867 file,
868 conflict,
869 part,
870 } => render_conflict(*file, *conflict, *part, &code, cx),
871 DisplayRow::Code {
872 file,
873 original,
874 modified,
875 changed,
876 } => {
877 if code.mode == DiffMode::Split && !code.files[*file].is_single_column() {
878 h_flex()
879 .items_stretch()
880 .w_full()
881 .child(render_cell(
882 *file,
883 DiffSide::Original,
884 *original,
885 *modified,
886 *changed,
887 code.clone(),
888 window,
889 cx,
890 ))
891 .child(render_cell(
892 *file,
893 DiffSide::Modified,
894 *modified,
895 *original,
896 *changed,
897 code,
898 window,
899 cx,
900 ))
901 .into_any_element()
902 } else {
903 let (side, ix) = match (original, modified) {
904 (_, Some(modified)) => (DiffSide::Modified, *modified),
905 (Some(original), None) => (DiffSide::Original, *original),
906 (None, None) => unreachable!("a code row shows at least one side"),
907 };
908 render_unified_cell(
909 *file,
910 side,
911 ix,
912 (*original, *modified),
913 *changed,
914 code,
915 window,
916 cx,
917 )
918 }
919 }
920 }
921}
922
923fn render_hunk_separator(
924 file: usize,
925 hunk: usize,
926 code: &CodePresentation,
927 cx: &App,
928) -> AnyElement {
929 let hunks = code.files[file].hunks();
930 let row = h_flex()
931 .w_full()
932 .px_3()
933 .bg(cx.theme().muted.opacity(0.35))
934 .text_xs()
935 .text_color(cx.theme().muted_foreground);
936 match code.hunk_separator {
937 DiffHunkSeparator::Metadata => row
938 .h_6()
939 .child(hunks[hunk].label().clone())
940 .into_any_element(),
941 DiffHunkSeparator::LineInfo => {
942 let hidden = hunks[hunk].hidden_lines_before(hunk.checked_sub(1).map(|ix| &hunks[ix]));
943 if hidden == 0 {
944 return div().into_any_element();
945 }
946 row.h_6()
947 .child(t!("Diff.HiddenLines", count = hidden).to_string())
948 .into_any_element()
949 }
950 DiffHunkSeparator::Simple => row.h_1().into_any_element(),
951 }
952}
953
954fn render_fold(
955 file: usize,
956 pairs: std::ops::Range<usize>,
957 code: &CodePresentation,
958 cx: &App,
959) -> AnyElement {
960 let document = &code.files[file];
961 let hunk = document
964 .hunks()
965 .iter()
966 .find(|hunk| hunk.pairs().contains(&pairs.start))
967 .map_or(0..document.pairs().len(), |hunk| hunk.pairs());
968 let lines = code.expansion_lines;
969 let partial = pairs.len() > lines;
970 let expand = |id: (&'static str, usize), expansion: FoldExpansion| {
971 let state = code.state.clone();
972 let pairs = pairs.clone();
973 Button::new(id)
974 .ghost()
975 .xsmall()
976 .w_auto()
977 .text_color(cx.theme().muted_foreground)
978 .on_click(move |_, _, cx| {
979 state.update(cx, |state, cx| {
980 state.expand_fold(file, pairs.clone(), expansion, cx)
981 })
982 })
983 };
984 h_flex()
985 .w_full()
986 .h_6()
987 .px_2()
988 .gap_1()
989 .bg(cx.theme().muted.opacity(0.35))
990 .when(partial && pairs.start > hunk.start, |this| {
991 this.child(
992 expand(("expand-down", pairs.start), FoldExpansion::Down)
993 .icon(IconName::ArrowDown)
994 .accessibility_label(t!("Diff.ExpandBelow", count = lines).to_string()),
995 )
996 })
997 .when(partial && pairs.end < hunk.end, |this| {
998 this.child(
999 expand(("expand-up", pairs.start), FoldExpansion::Up)
1000 .icon(IconName::ArrowUp)
1001 .accessibility_label(t!("Diff.ExpandAbove", count = lines).to_string()),
1002 )
1003 })
1004 .child(
1005 expand(("expand", pairs.start), FoldExpansion::All)
1006 .icon(if partial {
1007 IconName::ChevronsUpDown
1008 } else {
1009 IconName::ChevronDown
1010 })
1011 .label(t!("Diff.UnchangedLines", count = pairs.len()).to_string())
1012 .accessibility_label(t!("Diff.ExpandLines", count = pairs.len()).to_string()),
1013 )
1014 .into_any_element()
1015}
1016
1017fn render_conflict(
1020 file: usize,
1021 ix: usize,
1022 part: ConflictPart,
1023 code: &CodePresentation,
1024 cx: &App,
1025) -> AnyElement {
1026 let document = &code.files[file];
1027 let conflict = &document.conflicts()[ix];
1028 let resolution = code.state.read(cx).conflict_resolution(document.path(), ix);
1029 let choose = |id: &'static str, label: String, resolution: Option<DiffConflictResolution>| {
1030 let state = code.state.clone();
1031 Button::new((id, ix))
1032 .ghost()
1033 .xsmall()
1034 .w_auto()
1035 .label(label)
1036 .on_click(move |_, _, cx| {
1037 state.update(cx, |state, cx| {
1038 state.choose_conflict(file, ix, resolution, cx)
1039 })
1040 })
1041 };
1042 let row = h_flex()
1043 .w_full()
1044 .h_6()
1045 .px_3()
1046 .gap_2()
1047 .text_xs()
1048 .text_color(cx.theme().muted_foreground)
1049 .bg(conflict_tint(Some(part), cx).opacity(0.25));
1050 if let Some(resolution) = resolution {
1051 let label = match resolution {
1052 DiffConflictResolution::Current => t!("Diff.AcceptedCurrent"),
1053 DiffConflictResolution::Incoming => t!("Diff.AcceptedIncoming"),
1054 _ => t!("Diff.AcceptedBoth"),
1055 };
1056 return row
1057 .bg(cx.theme().muted.opacity(0.35))
1058 .child(label.to_string())
1059 .child(choose(
1060 "undo-resolution",
1061 t!("Diff.UndoResolution").to_string(),
1062 None,
1063 ))
1064 .into_any_element();
1065 }
1066 let title = match part {
1067 ConflictPart::Current => t!("Diff.CurrentChange"),
1068 ConflictPart::Base => t!("Diff.BaseChange"),
1069 ConflictPart::Incoming => t!("Diff.IncomingChange"),
1070 };
1071 let label = conflict.label(part).clone();
1072 row.child(div().font_medium().child(title.to_string()))
1073 .when(!label.is_empty(), |this| this.child(label))
1074 .when(part == ConflictPart::Current, |this| {
1075 this.child(div().flex_1())
1076 .child(choose(
1077 "accept-current",
1078 t!("Diff.AcceptCurrent").to_string(),
1079 Some(DiffConflictResolution::Current),
1080 ))
1081 .child(choose(
1082 "accept-incoming",
1083 t!("Diff.AcceptIncoming").to_string(),
1084 Some(DiffConflictResolution::Incoming),
1085 ))
1086 .child(choose(
1087 "accept-both",
1088 t!("Diff.AcceptBoth").to_string(),
1089 Some(DiffConflictResolution::Both),
1090 ))
1091 })
1092 .into_any_element()
1093}
1094
1095fn conflict_tint(part: Option<ConflictPart>, cx: &App) -> Hsla {
1097 match part {
1098 Some(ConflictPart::Current) => cx.theme().success,
1099 Some(ConflictPart::Incoming) => cx.theme().info,
1100 _ => cx.theme().muted_foreground,
1101 }
1102}
1103
1104fn render_file_header(
1105 file: usize,
1106 code: &CodePresentation,
1107 window: &mut Window,
1108 cx: &mut App,
1109) -> AnyElement {
1110 if !code.header_visible {
1111 return div()
1112 .children(file_annotations(file, code, window, cx))
1113 .into_any_element();
1114 }
1115 let document = &code.files[file];
1116 let foreground = cx.theme().muted_foreground;
1117 let border = cx.theme().border;
1118 let collapsed = code.state.read(cx).is_file_collapsed(document.path());
1119 let content = if let Some(render) = &code.header.content {
1120 render(document, window, cx)
1121 } else {
1122 let name: SharedString = match (document.original_path(), document.modified_path()) {
1123 (Some(old), Some(new)) if old != new => format!("{old} → {new}").into(),
1124 _ => document.path().clone(),
1125 };
1126 let slot = |render: &Option<FileRenderer>, window: &mut Window, cx: &mut App| {
1127 render.as_ref().map(|render| render(document, window, cx))
1128 };
1129 let prefix = slot(&code.header.prefix, window, cx);
1130 let title_suffix = slot(&code.header.title_suffix, window, cx);
1131 let suffix = slot(&code.header.suffix, window, cx);
1132 let status = match document.status() {
1133 DiffFileStatus::Added => Some(t!("Diff.AddedFile")),
1134 DiffFileStatus::Deleted => Some(t!("Diff.DeletedFile")),
1135 DiffFileStatus::Renamed => Some(t!("Diff.RenamedFile")),
1136 DiffFileStatus::Copied => Some(t!("Diff.CopiedFile")),
1137 DiffFileStatus::Conflicted => Some(t!("Diff.ConflictedFile")),
1138 _ => None,
1139 };
1140 let statistics = !document.is_single_column();
1141 h_flex()
1142 .w_full()
1143 .min_w_0()
1144 .gap_2()
1145 .children(prefix)
1146 .child(
1147 h_flex()
1148 .min_w_0()
1149 .flex_1()
1150 .gap_2()
1151 .child(div().min_w_0().truncate().font_medium().child(name))
1152 .when_some(title_suffix, |this, suffix| {
1153 this.child(div().flex_shrink_0().child(suffix))
1154 }),
1155 )
1156 .when(statistics, |this| {
1157 this.child(div().text_xs().text_color(foreground).child(format!(
1158 "+{} −{}",
1159 document.additions(),
1160 document.deletions()
1161 )))
1162 })
1163 .when_some(status, |this, status| {
1164 this.child(
1165 div()
1166 .text_xs()
1167 .text_color(foreground)
1168 .child(status.to_string()),
1169 )
1170 })
1171 .children(suffix)
1172 .into_any_element()
1173 };
1174 let state = code.state.clone();
1175 let name = document.path().clone();
1176 v_flex()
1177 .id("file-header")
1178 .w_full()
1179 .when(file > 0, |this| this.border_t_1())
1180 .border_b_1()
1181 .border_color(border)
1182 .child(
1183 h_flex()
1184 .w_full()
1185 .min_w_0()
1186 .pl_1()
1187 .pr_3()
1188 .py_1()
1189 .gap_1()
1190 .child(
1191 Button::new("collapse-file")
1192 .ghost()
1193 .xsmall()
1194 .icon(if collapsed {
1195 IconName::ChevronRight
1196 } else {
1197 IconName::ChevronDown
1198 })
1199 .accessibility_label(
1200 if collapsed {
1201 t!("Diff.ExpandFile", name = name.as_str())
1202 } else {
1203 t!("Diff.CollapseFile", name = name.as_str())
1204 }
1205 .to_string(),
1206 )
1207 .on_click(move |_, _, cx| {
1208 state.update(cx, |state, cx| state.toggle_file_collapsed(file, cx))
1209 }),
1210 )
1211 .child(div().flex_1().min_w_0().child(content)),
1212 )
1213 .children(file_annotations(file, code, window, cx))
1214 .into_any_element()
1215}
1216
1217fn file_annotations(
1218 file: usize,
1219 code: &CodePresentation,
1220 window: &mut Window,
1221 cx: &mut App,
1222) -> Vec<AnyElement> {
1223 let path = code.files[file].path();
1224 let ixs = code
1225 .annotations
1226 .files
1227 .get(path)
1228 .cloned()
1229 .unwrap_or_default();
1230 annotation_elements(&ixs, code, window, cx)
1231}
1232
1233fn render_notice(file: &DiffFile, cx: &App) -> AnyElement {
1235 let message = if !file.has_changes() {
1236 t!("Diff.NoChanges")
1237 } else if file.is_binary() {
1238 t!("Diff.BinaryChanges")
1239 } else if !file.extended_headers().is_empty() {
1240 t!("Diff.NoTextChanges")
1241 } else {
1242 t!("Diff.EmptyFile")
1243 };
1244 v_flex()
1245 .w_full()
1246 .px_3()
1247 .py_2()
1248 .gap_1()
1249 .text_xs()
1250 .text_color(cx.theme().muted_foreground)
1251 .children(
1252 file.extended_headers()
1253 .iter()
1254 .cloned()
1255 .map(|line| div().child(line)),
1256 )
1257 .child(div().text_sm().child(message.to_string()))
1258 .into_any_element()
1259}
1260
1261fn render_gutter(
1262 file: usize,
1263 side: DiffSide,
1264 ix: Option<usize>,
1265 code: &CodePresentation,
1266 cx: &App,
1267) -> AnyElement {
1268 let hover = matches!(
1269 code.hover_highlight,
1270 DiffHoverHighlight::LineNumber | DiffHoverHighlight::Both
1271 );
1272 div()
1273 .id(if side == DiffSide::Original {
1274 "old-gutter"
1275 } else {
1276 "new-gutter"
1277 })
1278 .flex_shrink_0()
1279 .w(code.gutter_width)
1280 .pr_1()
1281 .when(hover, |this| {
1282 this.hover(|this| this.bg(cx.theme().list_hover))
1283 })
1284 .when_some(ix, |this, ix| {
1285 let position = code.position(file, side, ix);
1286 let down = code.state.clone();
1287 let moved = code.state.clone();
1288 let dragged = position.clone();
1289 this.child(
1291 div()
1292 .id(("line", ix))
1293 .test_support()
1294 .w_full()
1295 .h(code.row_height)
1296 .px_1()
1297 .flex()
1298 .items_center()
1299 .justify_end()
1300 .text_size(code.font_size)
1301 .text_color(cx.theme().muted_foreground)
1302 .hover(|this| this.text_color(cx.theme().foreground))
1303 .role(gpui::accesskit::Role::Button)
1304 .aria_label(
1305 t!(
1306 "Diff.SelectLine",
1307 side = side_label(side),
1308 line = position.line()
1309 )
1310 .to_string(),
1311 )
1312 .child(position.line().to_string())
1313 .on_mouse_down(MouseButton::Left, move |event, window, cx| {
1314 down.update(cx, |state, cx| {
1315 state.begin_line_selection(
1316 position.clone(),
1317 event.modifiers.shift,
1318 window,
1319 cx,
1320 )
1321 })
1322 })
1323 .on_mouse_move(move |event, _, cx| {
1324 if event.pressed_button == Some(MouseButton::Left) {
1325 moved.update(cx, |state, cx| {
1326 state.drag_line_selection(dragged.clone(), cx)
1327 })
1328 }
1329 }),
1330 )
1331 })
1332 .into_any_element()
1333}
1334
1335fn side_label(side: DiffSide) -> std::borrow::Cow<'static, str> {
1336 if side == DiffSide::Original {
1337 t!("Diff.Original")
1338 } else {
1339 t!("Diff.Modified")
1340 }
1341}
1342
1343#[allow(clippy::too_many_arguments)]
1344fn render_cell(
1345 file: usize,
1346 side: DiffSide,
1347 ix: Option<usize>,
1348 other_ix: Option<usize>,
1349 changed: bool,
1350 code: Rc<CodePresentation>,
1351 window: &mut Window,
1352 cx: &mut App,
1353) -> AnyElement {
1354 let cell = v_flex()
1355 .w(code.column_width)
1356 .min_w_0()
1357 .flex_shrink_0()
1358 .when(side == DiffSide::Original, |this| {
1359 this.border_r_1().border_color(cx.theme().border)
1360 })
1361 .when(ix.is_none(), |this| {
1362 this.bg(cx.theme().muted.opacity(0.3))
1363 .child(div().h(code.row_height))
1364 });
1365 let Some(ix) = ix else {
1366 return cell.into_any_element();
1367 };
1368 cell.id((
1369 if side == DiffSide::Original {
1370 "old"
1371 } else {
1372 "new"
1373 },
1374 ix,
1375 ))
1376 .child(code_line(
1377 file,
1378 side,
1379 ix,
1380 changed,
1381 code.clone(),
1382 [Some((side, Some(ix))), None],
1383 cx,
1384 ))
1385 .children(line_extras(
1386 file, side, ix, other_ix, changed, &code, window, cx,
1387 ))
1388 .into_any_element()
1389}
1390
1391#[allow(clippy::too_many_arguments)]
1392fn render_unified_cell(
1393 file: usize,
1394 side: DiffSide,
1395 ix: usize,
1396 pair: (Option<usize>, Option<usize>),
1397 changed: bool,
1398 code: Rc<CodePresentation>,
1399 window: &mut Window,
1400 cx: &mut App,
1401) -> AnyElement {
1402 let (original, modified) = pair;
1403 let single_column = code.files[file].is_single_column();
1404 let counterpart = code.files[file].lines(side)[ix].counterpart();
1405 let other_ix = if side == DiffSide::Original {
1406 modified
1407 } else {
1408 original
1409 };
1410 let mut extras = line_extras(file, side, ix, counterpart, changed, &code, window, cx);
1411 if !changed && let Some(other_ix) = other_ix {
1412 extras.extend(line_annotations(
1413 file,
1414 side.other(),
1415 other_ix,
1416 &code,
1417 window,
1418 cx,
1419 ));
1420 }
1421 let gutters = if single_column {
1422 [Some((DiffSide::Modified, modified)), None]
1423 } else {
1424 [
1425 Some((DiffSide::Original, original)),
1426 Some((DiffSide::Modified, modified)),
1427 ]
1428 };
1429 v_flex()
1430 .w(if single_column {
1431 code.content_width
1432 } else {
1433 code.column_width
1434 })
1435 .id((
1436 if side == DiffSide::Original {
1437 "old"
1438 } else {
1439 "new"
1440 },
1441 ix,
1442 ))
1443 .child(code_line(
1444 file,
1445 side,
1446 ix,
1447 changed,
1448 code.clone(),
1449 gutters,
1450 cx,
1451 ))
1452 .children(extras)
1453 .into_any_element()
1454}
1455
1456#[allow(clippy::too_many_arguments)]
1458fn line_highlights(
1459 file: usize,
1460 side: DiffSide,
1461 ix: usize,
1462 changed: bool,
1463 selected: bool,
1464 status: Hsla,
1465 code: &CodePresentation,
1466 cx: &App,
1467) -> Vec<(std::ops::Range<usize>, HighlightStyle)> {
1468 let line = &code.files[file].lines(side)[ix];
1469 let presentation = code.presentations.get(file).and_then(Option::as_ref);
1470 let mut highlights = match presentation {
1471 Some(presentation) if code.syntax_highlight => {
1472 presentation.syntax_highlights(line, side, ix, cx.theme().highlight_theme.as_ref())
1473 }
1474 _ => Vec::new(),
1475 };
1476 if changed && let Some(presentation) = presentation {
1477 highlights.extend(presentation.inline_changes(line, side, ix).map(|range| {
1478 (
1479 range,
1480 HighlightStyle {
1481 background_color: Some(status.opacity(0.3)),
1482 ..Default::default()
1483 },
1484 )
1485 }));
1486 }
1487 if selected {
1488 highlights.push((
1489 0..line.display().len(),
1490 HighlightStyle {
1491 background_color: Some(cx.theme().selection),
1492 ..Default::default()
1493 },
1494 ));
1495 }
1496 highlights
1497}
1498
1499fn add_annotation_button(
1501 file: usize,
1502 side: DiffSide,
1503 ix: usize,
1504 left: Pixels,
1505 code: &CodePresentation,
1506 cx: &App,
1507) -> Option<AnyElement> {
1508 let handler = code.on_add_annotation.clone()?;
1509 let position = code.position(file, side, ix);
1510 let range = code
1511 .selected_lines
1512 .clone()
1513 .filter(|_| code.is_selected(file, side, ix))
1514 .unwrap_or_else(|| {
1515 DiffLineRange::new(
1516 position.path().clone(),
1517 side,
1518 position.line(),
1519 position.line(),
1520 )
1521 });
1522 let at_selection_end = code
1523 .selected
1524 .as_ref()
1525 .is_some_and(|selected| selected.is_last(&code.files, file, side, ix));
1526 Some(
1527 div()
1528 .id(("add-annotation", ix))
1529 .test_support()
1530 .absolute()
1531 .top_0()
1532 .left(left)
1533 .size(code.row_height)
1534 .flex()
1535 .items_center()
1536 .justify_center()
1537 .rounded(cx.theme().radius)
1538 .bg(cx.theme().primary)
1539 .text_color(cx.theme().primary_foreground)
1540 .when(!at_selection_end, |this| {
1541 this.opacity(0.)
1542 .group_hover("diff-line", |this| this.opacity(1.))
1543 })
1544 .role(gpui::accesskit::Role::Button)
1545 .aria_label(t!("Diff.AddAnnotation", line = position.line()).to_string())
1546 .child(Icon::new(IconName::Plus).xsmall())
1547 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
1548 .on_click(move |_, window, cx| handler(&range, window, cx))
1549 .into_any_element(),
1550 )
1551}
1552
1553fn code_line(
1554 file: usize,
1555 side: DiffSide,
1556 ix: usize,
1557 changed: bool,
1558 code: Rc<CodePresentation>,
1559 gutters: [Option<(DiffSide, Option<usize>)>; 2],
1560 cx: &mut App,
1561) -> AnyElement {
1562 let document = &code.files[file];
1563 let line = &document.lines(side)[ix];
1564 let conflict_part = (document.status() == DiffFileStatus::Conflicted && changed)
1565 .then(|| {
1566 document
1567 .conflicts()
1568 .iter()
1569 .find(|conflict| conflict.source_lines().contains(&ix))
1570 .and_then(|conflict| conflict.part_of(ix))
1571 })
1572 .flatten();
1573 let status = if conflict_part.is_some() {
1574 conflict_tint(conflict_part, cx)
1575 } else if side == DiffSide::Original {
1576 cx.theme().danger
1577 } else {
1578 cx.theme().success
1579 };
1580 let selected = code.is_selected(file, side, ix)
1581 || (code.mode == DiffMode::Unified
1582 && !changed
1583 && gutters
1584 .iter()
1585 .flatten()
1586 .any(|(side, ix)| ix.is_some_and(|ix| code.is_selected(file, *side, ix))));
1587 let highlights = line_highlights(file, side, ix, changed, selected, status, &code, cx);
1588 let (text_side, text_ix) = if code.mode == DiffMode::Unified
1591 && !changed
1592 && let Some((selected_file, selected_side, _)) =
1593 selection::selected_source_range(&code.selection, cx)
1594 && selected_file == file
1595 && selected_side != side
1596 && let Some(other_ix) = line.counterpart()
1597 {
1598 (selected_side, other_ix)
1599 } else {
1600 (side, ix)
1601 };
1602 let position = code.position(file, text_side, text_ix);
1603 let source_line = &document.lines(text_side)[text_ix];
1604 let ranges: Vec<_> = if code.soft_wrap {
1607 vec![0..source_line.display().len()]
1608 } else {
1609 source_line.chunk_ranges().collect()
1610 };
1611 let text = div()
1613 .map(|this| {
1614 if code.soft_wrap {
1615 this.w_full()
1616 } else {
1617 this.flex().flex_row().flex_shrink_0()
1618 }
1619 })
1620 .children(ranges.into_iter().enumerate().map(|(chunk_ix, range)| {
1621 CodeText::new(
1622 ("code", chunk_ix).into(),
1623 document.clone(),
1624 file,
1625 text_side,
1626 text_ix,
1627 range,
1628 highlights.clone(),
1629 code.selection.clone(),
1630 code.geometry.clone(),
1631 )
1632 }));
1633 let gutters_width = if code.line_number {
1634 code.gutter_width * gutters.iter().flatten().count() as f32
1635 } else {
1636 px(0.)
1637 };
1638 let indicator = match code.change_indicator {
1639 DiffChangeIndicator::Signs => div()
1640 .w_4()
1641 .flex_shrink_0()
1642 .text_color(if changed {
1643 status
1644 } else {
1645 cx.theme().muted_foreground
1646 })
1647 .child(match (changed && conflict_part.is_none(), side) {
1648 (false, _) => " ",
1649 (true, DiffSide::Original) => "−",
1650 (true, DiffSide::Modified) => "+",
1651 }),
1652 DiffChangeIndicator::Bars => div().w_4().h_full().flex_shrink_0().when(changed, |this| {
1653 this.child(div().w(px(3.)).h_full().bg(status))
1654 }),
1655 DiffChangeIndicator::None => div().w_2().flex_shrink_0(),
1656 };
1657 let hover_line = matches!(
1658 code.hover_highlight,
1659 DiffHoverHighlight::Line | DiffHoverHighlight::Both
1660 );
1661 let add_button =
1662 add_annotation_button(file, side, ix, gutters_width + code.rem / 2., &code, cx);
1663 h_flex()
1664 .id("line-row")
1665 .group("diff-line")
1666 .relative()
1667 .w_full()
1668 .px_2()
1669 .map(|this| {
1670 if code.soft_wrap {
1671 this.min_h(code.row_height)
1672 .items_start()
1673 .whitespace_normal()
1674 } else {
1675 this.h(code.row_height).whitespace_nowrap()
1676 }
1677 })
1678 .font_family(cx.theme().mono_font_family.clone())
1679 .text_size(code.font_size)
1680 .line_height(code.row_height)
1681 .when(changed && code.change_background, |this| {
1682 this.bg(status.opacity(0.12))
1683 })
1684 .when(selected, |this| this.bg(cx.theme().selection))
1685 .when(hover_line && !selected, |this| {
1686 this.hover(|this| this.bg(cx.theme().list_hover))
1687 })
1688 .when_some(code.on_line_hover.clone(), |this, handler| {
1689 let position = position.clone();
1690 this.on_hover(move |hovered, window, cx| {
1691 handler(hovered.then_some(&position), window, cx)
1692 })
1693 })
1694 .when(code.line_number, |this| {
1695 this.children(
1696 gutters
1697 .into_iter()
1698 .flatten()
1699 .map(|(side, ix)| render_gutter(file, side, ix, &code, cx)),
1700 )
1701 })
1702 .child(indicator)
1703 .child(
1704 div()
1705 .id("source")
1706 .test_support()
1707 .map(|this| {
1708 if code.soft_wrap {
1709 this.flex_1().min_w_0()
1710 } else {
1711 this.flex_shrink_0()
1712 }
1713 })
1714 .role(gpui::accesskit::Role::Label)
1715 .aria_label(source_line.text().clone())
1716 .aria_description(
1717 t!(
1718 if changed {
1719 if side == DiffSide::Original {
1720 "Diff.RemovedLine"
1721 } else {
1722 "Diff.AddedLine"
1723 }
1724 } else {
1725 "Diff.SourceLine"
1726 },
1727 side = side_label(text_side),
1728 line = position.line()
1729 )
1730 .to_string(),
1731 )
1732 .when_some(code.on_line_click.clone(), |this, handler| {
1733 let position = position.clone();
1734 this.on_click(move |event, window, cx| {
1735 if let ClickEvent::Mouse(mouse) = event {
1737 let moved = mouse.up.position - mouse.down.position;
1738 if moved.x.abs() > px(3.) || moved.y.abs() > px(3.) {
1739 return;
1740 }
1741 }
1742 handler(&position, event, window, cx)
1743 })
1744 })
1745 .child(text),
1746 )
1747 .children(add_button)
1748 .into_any_element()
1749}
1750
1751#[allow(clippy::too_many_arguments)]
1752fn line_extras(
1753 file: usize,
1754 side: DiffSide,
1755 ix: usize,
1756 other_ix: Option<usize>,
1757 changed: bool,
1758 code: &CodePresentation,
1759 window: &mut Window,
1760 cx: &mut App,
1761) -> Vec<AnyElement> {
1762 let mut extras = Vec::new();
1763 let document = &code.files[file];
1764 let line = &document.lines(side)[ix];
1765 let ending = &document.source(side)[line.content_end()..line.source().end];
1766 let ending_only = changed && has_line_ending_change(document, side, ix, other_ix);
1767 if ending.is_empty() || ending_only {
1768 let label = if ending.is_empty() {
1769 t!("Diff.NoNewline").to_string()
1770 } else {
1771 format!("\\ {}", if ending == "\r\n" { "CRLF" } else { "LF" })
1772 };
1773 extras.push(
1774 div()
1775 .h_6()
1776 .px_3()
1777 .text_xs()
1778 .text_color(cx.theme().muted_foreground)
1779 .child(label)
1780 .into_any_element(),
1781 );
1782 }
1783 extras.extend(line_annotations(file, side, ix, code, window, cx));
1784 extras
1785}
1786
1787fn line_annotations(
1788 file: usize,
1789 side: DiffSide,
1790 ix: usize,
1791 code: &CodePresentation,
1792 window: &mut Window,
1793 cx: &mut App,
1794) -> Vec<AnyElement> {
1795 let position = code.position(file, side, ix);
1796 let ixs = code
1797 .annotations
1798 .lines
1799 .get(&position)
1800 .cloned()
1801 .unwrap_or_default();
1802 annotation_elements(&ixs, code, window, cx)
1803}
1804
1805fn annotation_elements(
1806 ixs: &[usize],
1807 code: &CodePresentation,
1808 window: &mut Window,
1809 cx: &mut App,
1810) -> Vec<AnyElement> {
1811 let Some(render) = &code.annotation_content else {
1812 return Vec::new();
1813 };
1814 ixs.iter()
1815 .map(|ix| {
1816 let annotation = &code.annotations.items[*ix];
1817 div()
1818 .id(annotation.id.clone())
1819 .w_full()
1820 .px_3()
1821 .py_3()
1822 .border_t_1()
1823 .border_b_1()
1824 .border_color(cx.theme().border)
1825 .bg(cx.theme().muted.opacity(0.18))
1826 .child(render(annotation, window, cx))
1827 .into_any_element()
1828 })
1829 .collect()
1830}
1831
1832fn has_line_ending_change(
1833 file: &DiffFile,
1834 side: DiffSide,
1835 ix: usize,
1836 counterpart: Option<usize>,
1837) -> bool {
1838 counterpart.is_some_and(|counterpart| {
1839 let other_side = side.other();
1840 let line = &file.lines(side)[ix];
1841 let other = &file.lines(other_side)[counterpart];
1842 line.text() == other.text()
1843 && file.source(side)[line.content_end()..line.source().end]
1844 != file.source(other_side)[other.content_end()..other.source().end]
1845 })
1846}
1847
1848#[cfg(test)]
1849mod copy_tests;
1850#[cfg(test)]
1851mod header_tests;
1852#[cfg(test)]
1853mod newline_tests;
1854#[cfg(test)]
1855mod review_tests;
1856#[cfg(test)]
1857mod selection_tests;
1858#[cfg(test)]
1859mod tests;