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