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