use std::rc::Rc; use gpui::{ AnyElement, App, Div, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, uniform_list, }; use similar::DiffableStr; use super::merging::{MARKER, Region, RegionKind, Take, conflicts, resolve}; use crate::{ buttons::{Button, ButtonVariant}, i18n, primitives::{Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Palette, Radius, TextSize}, }; type OnTake = Rc; type OnText = Rc; /// The three buttons that settle a conflict. fn takes( id: &ElementId, conflict: usize, on_take: &Option, words: [SharedString; 3], ) -> Div { let [ours, theirs, both] = words; div().flex().gap_1().children( [ (Take::Ours, ours), (Take::Theirs, theirs), (Take::Both, both), ] .map(|(take, words)| { let on_take = on_take.clone(); Button::new((id.clone(), format!("take-{conflict}-{take:?}")), words) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .when_some(on_take, |button, on_take| { button.on_click(move |_, window, cx| on_take(conflict, take, window, cx)) }) }), ) } /// Ours, base and theirs side by side, rows in view. #[derive(IntoElement)] pub struct ThreeWayMerge { id: ElementId, labels: [SharedString; 3], regions: Rc>, takes: Vec>, on_take: Option, } impl ThreeWayMerge { /// `labels` name ours, the base and theirs. pub fn new(id: impl Into, labels: [&str; 3], regions: Rc>) -> Self { Self { id: id.into(), labels: labels.map(|label| label.to_string().into()), regions, takes: Vec::new(), on_take: None, } } /// The side each conflict took so far, in order. pub fn takes(mut self, takes: impl IntoIterator>) -> Self { self.takes = takes.into_iter().collect(); self } pub fn on_take( mut self, handler: impl Fn(usize, Take, &mut Window, &mut App) + 'static, ) -> Self { self.on_take = Some(Rc::new(handler)); self } } fn washes(kind: RegionKind, colors: &Palette) -> [Option; 3] { let changed = colors.success.opacity(0.1); match kind { RegionKind::Unchanged => [None, None, None], RegionKind::Ours => [Some(changed), None, None], RegionKind::Theirs => [None, None, Some(changed)], RegionKind::Both => [Some(changed), None, Some(changed)], RegionKind::Conflict => [Some(colors.warning.opacity(0.14)); 3], } } /// A row as the merge draws it. #[derive(Clone)] enum Line { /// A conflict's place, before its lines. Conflict(usize), /// A line of each side, empty past its end. Code(RegionKind, [Option; 3]), } fn rows(regions: &[Region]) -> Vec { let mut rows = Vec::new(); let mut conflict = 0; for region in regions { if region.kind == RegionKind::Conflict { rows.push(Line::Conflict(conflict)); conflict += 1; } let count = region .ours .len() .max(region.base.len()) .max(region.theirs.len()); let line = |side: &[String], ix: usize| { side.get(ix) .map(|line| line.trim_end_matches(['\r', '\n']).to_string()) }; rows.extend((0..count).map(|ix| { Line::Code( region.kind, [ line(®ion.ours, ix), line(®ion.base, ix), line(®ion.theirs, ix), ], ) })); } rows } impl RenderOnce for ThreeWayMerge { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); let height = theme.control_height(ControlSize::Sm); let rows = Rc::new(rows(&self.regions)); let Self { id, labels, takes: taken, on_take, .. } = self; let header = div() .flex() .gap_px() .bg(colors.hover) .font_weight(FontWeight::MEDIUM) .text_color(colors.fg_muted) .children( labels .iter() .map(|label| div().flex_1().min_w_0().px_2().py_1().child(label.clone())), ); let list = uniform_list((id.clone(), "rows"), rows.len(), move |range, _, cx| { let colors = cx.theme().colors.clone(); range .map(|ix| { let row = div() .id((id.clone(), format!("row-{ix}"))) .w_full() .h(height) .flex() .items_center(); match &rows[ix] { Line::Conflict(at) => { let said = match taken.get(*at).copied().flatten() { Some(take) => i18n::text( cx, "git.merge.took", &[ ("n", &(at + 1).to_string()), ("side", &i18n::text(cx, word(take), &[])), ], ), None => i18n::text( cx, "git.merge.conflict", &[("n", &(at + 1).to_string())], ), }; let words = [ "git.merge.take_ours", "git.merge.take_theirs", "git.merge.take_both", ] .map(|key| i18n::text(cx, key, &[])); row.gap_2() .px_2() .role(Role::Heading) .aria_label(said.clone()) .border_b_1() .border_color(colors.border) .font_family(cx.theme().font_family.clone()) .child( Icon::new(IconName::TriangleAlert) .size(IconSize::Sm) .color(colors.warning), ) .child(div().flex_1().text_color(colors.fg_muted).child(said)) .child(takes(&id, *at, &on_take, words)) .into_any_element() } Line::Code(kind, sides) => { let washes = washes(*kind, &colors); // Each side named by its label, for assistive tech. let said: Vec = labels .iter() .zip(sides) .map(|(label, line)| { format!("{label}: {}", line.as_deref().unwrap_or("")) }) .collect(); row.gap_px() .role(Role::Label) .aria_label(SharedString::from(said.join(" ยท "))) .children(sides.iter().zip(washes).map(|(line, wash)| { div() .flex_1() .min_w_0() .h_full() .px_2() .flex() .items_center() .whitespace_nowrap() .overflow_hidden() .when_some(wash, |side, wash| side.bg(wash)) .children(line.clone()) })) .into_any_element() } } }) .collect() }) .flex_1(); div() .size_full() .flex() .flex_col() .rounded(theme.radius(Radius::Md)) .border_1() .border_color(colors.border) .overflow_hidden() .text_size(theme.text_size(TextSize::Sm)) .child(header) .child( div() .flex_1() .min_h_0() .flex() .flex_col() .font_family(theme.mono_family.clone()) .text_size(theme.text_size(TextSize::Xs)) .child(list), ) } } fn word(take: Take) -> &'static str { match take { Take::Ours => "git.merge.ours", Take::Theirs => "git.merge.theirs", Take::Both => "git.merge.both", } } /// Marked conflicts, each accepting current, incoming, or both. #[derive(IntoElement)] pub struct ConflictResolver { id: ElementId, text: SharedString, on_resolve: Option, } impl ConflictResolver { pub fn new(id: impl Into, text: impl Into) -> Self { Self { id: id.into(), text: text.into(), on_resolve: None, } } /// Gets the whole text once a conflict settles. pub fn on_resolve(mut self, handler: impl Fn(String, &mut Window, &mut App) + 'static) -> Self { self.on_resolve = Some(Rc::new(handler)); self } } impl RenderOnce for ConflictResolver { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); let found = conflicts(&self.text, MARKER); let text = self.text.to_string(); let on_take: Option = self.on_resolve.map(|on_resolve| { Rc::new( move |ix: usize, take: Take, window: &mut Window, cx: &mut App| { if let Some(settled) = resolve(&text, ix, take, MARKER) { on_resolve(settled, window, cx) } }, ) as OnTake }); let (current, incoming) = (colors.success.opacity(0.1), colors.info.opacity(0.1)); let mut rows: Vec = Vec::new(); for (ix, line) in self.text.tokenize_lines().into_iter().enumerate() { let line = line.trim_end_matches(['\r', '\n']); if let Some(at) = found.iter().position(|conflict| conflict.start == ix) { rows.push( takes( &self.id, at, &on_take, [ "git.merge.accept_current", "git.merge.accept_incoming", "git.merge.accept_both", ] .map(|key| i18n::text(cx, key, &[])), ) .py_0p5() .font_family(theme.font_family.clone()) .into_any_element(), ); } let wash = found.iter().find_map(|conflict| { if ix == conflict.start { Some(current.opacity(2.0)) } else if ix > conflict.start && ix < conflict.middle { Some(current) } else if ix > conflict.middle && ix < conflict.end { Some(incoming) } else if ix == conflict.end { Some(incoming.opacity(2.0)) } else { None } }); let marker = found .iter() .any(|conflict| [conflict.start, conflict.middle, conflict.end].contains(&ix)); rows.push( div() .px_2() .whitespace_nowrap() .when_some(wash, |row, wash| row.bg(wash)) .when(marker, |row| row.text_color(colors.fg_muted)) .child(line.to_string()) .into_any_element(), ); } div() .flex() .flex_col() .rounded(theme.radius(Radius::Md)) .border_1() .border_color(colors.border) .py_1() .font_family(theme.mono_family.clone()) .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg) .children(rows) } }