use std::collections::BTreeSet;
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::{Selectable as _, Sizable as _, h_flex, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::{
AnyElement, ClipboardItem, Context, EventEmitter, FontWeight, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString,
StatefulInteractiveElement as _, Styled as _, TestSupportExt as _, Window, div, px,
};
use crate::diff::{ARRAY_SUMMARY, CONTEXT, Diff, DiffLine, LineKind};
use crate::theme::{self, color};
use crate::widgets::icon_button;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HunkDecision {
pub hunk: usize,
pub accept: bool,
}
pub struct DiffView {
id: SharedString,
title: Option<SharedString>,
diff: Diff,
expanded: BTreeSet<usize>,
reviewable: bool,
decisions: Vec<Option<bool>>,
}
impl EventEmitter<HunkDecision> for DiffView {}
impl DiffView {
pub fn new(id: impl Into<SharedString>, diff: Diff) -> Self {
let decisions = vec![None; diff.hunks.len()];
Self { id: id.into(), title: None, diff, expanded: BTreeSet::new(), reviewable: false, decisions }
}
pub fn text(id: impl Into<SharedString>, old: &str, new: &str) -> Self {
Self::new(id, Diff::text(old, new, CONTEXT))
}
pub fn json(id: impl Into<SharedString>, old: &str, new: &str) -> Self {
Self::new(id, Diff::json(old, new, CONTEXT, ARRAY_SUMMARY))
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn reviewable(mut self, reviewable: bool) -> Self {
self.reviewable = reviewable;
self
}
pub fn set_diff(&mut self, diff: Diff, cx: &mut Context<Self>) {
self.decisions = vec![None; diff.hunks.len()];
self.expanded.clear();
self.diff = diff;
cx.notify();
}
pub fn diff(&self) -> &Diff {
&self.diff
}
pub fn decisions(&self) -> &[Option<bool>] {
&self.decisions
}
pub fn decide(&mut self, hunk: usize, accept: bool, cx: &mut Context<Self>) {
if let Some(d) = self.decisions.get_mut(hunk) {
*d = Some(accept);
cx.emit(HunkDecision { hunk, accept });
cx.notify();
}
}
pub fn toggle_gap(&mut self, gap: usize, cx: &mut Context<Self>) {
if !self.expanded.remove(&gap) {
self.expanded.insert(gap);
}
cx.notify();
}
fn header(&self, cx: &mut Context<Self>) -> impl IntoElement {
let (added, removed) = (self.diff.added(), self.diff.removed());
let unified = self.diff.unified();
let copy = icon_button(SharedString::from(format!("{}-copy", self.id)), IconName::Copy, "Copy the diff").on_click(move |_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(unified.clone()));
});
let all_open = self.expanded.len() == self.diff.gaps().len();
let gaps = self.diff.gaps().len();
let unfold = icon_button(SharedString::from(format!("{}-unfold", self.id)), if all_open { IconName::ChevronUp } else { IconName::ChevronsUpDown }, if all_open { "Fold the unchanged lines" } else { "Show every line" })
.on_click(cx.listener(move |this, _, _, cx| {
if this.expanded.len() == gaps {
this.expanded.clear();
} else {
this.expanded = (0..gaps).collect();
}
cx.notify();
}));
h_flex()
.w_full()
.gap(px(theme::SM))
.px(px(theme::SM))
.py(px(theme::XS))
.bg(color::raised())
.border_b_1()
.border_color(color::border())
.when_some(self.title.clone(), |d, t| d.child(div().min_w_0().truncate().text_sm().font_weight(FontWeight::SEMIBOLD).child(t)))
.child(div().text_sm().font_family("monospace").text_color(color::success()).child(format!("+{added}")))
.child(div().text_sm().font_family("monospace").text_color(color::danger()).child(format!("−{removed}")))
.when(self.diff.json, |d| d.child(div().text_xs().text_color(color::text_muted()).child("JSON, by key")))
.child(div().flex_1())
.when(gaps > 0, |d| d.child(unfold))
.child(copy)
}
fn hunk_header(&self, i: usize, cx: &mut Context<Self>) -> impl IntoElement {
let h = &self.diff.hunks[i];
let decided = self.decisions.get(i).copied().flatten();
let review = self.reviewable.then(|| {
let accept = Button::new(SharedString::from(format!("{}-accept-{i}", self.id)))
.small()
.icon(IconName::Check)
.label("Accept")
.when(decided == Some(true), |b| b.primary())
.when(decided != Some(true), |b| b.outline())
.selected(decided == Some(true))
.tooltip("Keep this change")
.on_click(cx.listener(move |this, _, _, cx| this.decide(i, true, cx)));
let reject = Button::new(SharedString::from(format!("{}-reject-{i}", self.id)))
.small()
.icon(IconName::Close)
.label("Reject")
.when(decided == Some(false), |b| b.danger())
.when(decided != Some(false), |b| b.outline())
.selected(decided == Some(false))
.tooltip("Drop this change")
.on_click(cx.listener(move |this, _, _, cx| this.decide(i, false, cx)));
h_flex().gap(px(theme::XS)).child(accept).child(reject)
});
let status = decided.map(|a| {
let (text, c) = if a { ("Accepted", color::success()) } else { ("Rejected", color::danger()) };
div().text_xs().font_weight(FontWeight::SEMIBOLD).text_color(c).child(text)
});
h_flex()
.w_full()
.min_h(px(theme::TARGET_MIN + theme::XS))
.gap(px(theme::SM))
.px(px(theme::SM))
.bg(color::raised())
.border_t_1()
.border_b_1()
.border_color(color::border())
.child(div().flex_1().min_w_0().truncate().text_xs().font_family("monospace").text_color(color::text_muted()).child(h.header()))
.children(status)
.children(review)
}
fn gap_row(&self, gap: usize, len: usize, cx: &mut Context<Self>) -> impl IntoElement {
let label = if len == 1 { "⋯ 1 unchanged line".to_string() } else { format!("⋯ {} unchanged lines", crate::diff::group_digits(len)) };
h_flex()
.id(SharedString::from(format!("{}-gap-{gap}", self.id)))
.w_full()
.min_h(px(theme::TARGET_MIN))
.px(px(theme::SM))
.bg(color::panel())
.text_xs()
.text_color(color::text_muted())
.cursor_pointer()
.hover(|d| d.bg(color::hover()))
.child(label)
.on_click(cx.listener(move |this, _, _, cx| this.toggle_gap(gap, cx)))
.test_support()
}
}
fn line_row(l: &DiffLine) -> AnyElement {
let (bg, sign, sign_color): (Option<Hsla>, &str, Hsla) = match l.kind {
LineKind::Context => (None, " ", color::text_muted()),
LineKind::Added => (Some(color::diff_added()), "+", color::success()),
LineKind::Removed => (Some(color::diff_removed()), "−", color::danger()),
LineKind::Summary => (Some(color::control()), "~", color::text_muted()),
};
let num = |n: Option<usize>| div().flex_none().w(px(40.)).pr(px(theme::XS)).text_right().text_color(color::text_muted()).child(n.map(|n| n.to_string()).unwrap_or_default());
h_flex()
.w_full()
.items_start()
.font_family("monospace")
.text_xs()
.when_some(bg, |d, bg| d.bg(bg))
.child(num(l.old))
.child(num(l.new))
.child(div().flex_none().w(px(16.)).text_center().font_weight(FontWeight::SEMIBOLD).text_color(sign_color).child(sign))
.child(div().flex_1().min_w_0().pr(px(theme::SM)).when(l.kind == LineKind::Summary, |d| d.italic()).child(if l.text.is_empty() { " ".to_string() } else { l.text.clone() }))
.into_any_element()
}
impl Render for DiffView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let gaps = self.diff.gaps();
let mut body: Vec<AnyElement> = Vec::new();
if let Some(note) = &self.diff.note {
body.push(div().px(px(theme::SM)).py(px(theme::XS)).text_xs().text_color(color::warning()).child(note.clone()).into_any_element());
}
if self.diff.is_empty() {
body.push(div().px(px(theme::SM)).py(px(theme::SM)).text_sm().text_color(color::text_muted()).child("No changes.").into_any_element());
} else {
let mut gi = 0;
for hi in 0..self.diff.hunks.len() {
let start = self.diff.hunks[hi].lines.start;
while gi < gaps.len() && gaps[gi].end <= start {
body.extend(self.gap(gi, &gaps, cx));
gi += 1;
}
body.push(self.hunk_header(hi, cx).into_any_element());
body.extend(self.diff.lines[self.diff.hunks[hi].lines.clone()].iter().map(line_row));
}
while gi < gaps.len() {
body.extend(self.gap(gi, &gaps, cx));
gi += 1;
}
}
v_flex()
.id(self.id.clone())
.size_full()
.rounded(px(theme::RADIUS_LG))
.border_1()
.border_color(color::border())
.bg(color::panel())
.overflow_hidden()
.child(self.header(cx))
.child(v_flex().id(SharedString::from(format!("{}-body", self.id))).flex_1().min_h_0().w_full().overflow_y_scroll().children(body))
}
}
impl DiffView {
fn gap(&self, gi: usize, gaps: &[std::ops::Range<usize>], cx: &mut Context<Self>) -> Vec<AnyElement> {
let range = gaps[gi].clone();
if self.expanded.contains(&gi) {
let mut out = vec![self.gap_row_open(gi, cx).into_any_element()];
out.extend(self.diff.lines[range].iter().map(line_row));
out
} else {
vec![self.gap_row(gi, range.len(), cx).into_any_element()]
}
}
fn gap_row_open(&self, gap: usize, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.id(SharedString::from(format!("{}-gap-{gap}", self.id)))
.w_full()
.min_h(px(theme::TARGET_MIN))
.px(px(theme::SM))
.text_xs()
.text_color(color::text_muted())
.cursor_pointer()
.hover(|d| d.bg(color::hover()))
.child("Fold the unchanged lines")
.on_click(cx.listener(move |this, _, _, cx| this.toggle_gap(gap, cx)))
.test_support()
}
}