1use std::collections::BTreeSet;
5
6use gpui_kit::assets::IconName;
7use gpui_kit::component::button::{Button, ButtonVariants as _};
8use gpui_kit::component::{Selectable as _, Sizable as _, h_flex, v_flex};
9use gpui_kit::prelude::FluentBuilder as _;
10use gpui_kit::{
11 AnyElement, ClipboardItem, Context, EventEmitter, FontWeight, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString,
12 StatefulInteractiveElement as _, Styled as _, TestSupportExt as _, Window, div, px,
13};
14
15use crate::diff::{ARRAY_SUMMARY, CONTEXT, Diff, DiffLine, LineKind};
16use crate::theme::{self, color};
17use crate::widgets::icon_button;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub struct HunkDecision {
22 pub hunk: usize,
24 pub accept: bool,
26}
27
28pub struct DiffView {
37 id: SharedString,
38 title: Option<SharedString>,
39 diff: Diff,
40 expanded: BTreeSet<usize>,
41 reviewable: bool,
42 decisions: Vec<Option<bool>>,
43}
44
45impl EventEmitter<HunkDecision> for DiffView {}
46
47impl DiffView {
48 pub fn new(id: impl Into<SharedString>, diff: Diff) -> Self {
50 let decisions = vec![None; diff.hunks.len()];
51 Self { id: id.into(), title: None, diff, expanded: BTreeSet::new(), reviewable: false, decisions }
52 }
53
54 pub fn text(id: impl Into<SharedString>, old: &str, new: &str) -> Self {
56 Self::new(id, Diff::text(old, new, CONTEXT))
57 }
58
59 pub fn json(id: impl Into<SharedString>, old: &str, new: &str) -> Self {
62 Self::new(id, Diff::json(old, new, CONTEXT, ARRAY_SUMMARY))
63 }
64
65 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
67 self.title = Some(title.into());
68 self
69 }
70
71 pub fn reviewable(mut self, reviewable: bool) -> Self {
73 self.reviewable = reviewable;
74 self
75 }
76
77 pub fn set_diff(&mut self, diff: Diff, cx: &mut Context<Self>) {
79 self.decisions = vec![None; diff.hunks.len()];
80 self.expanded.clear();
81 self.diff = diff;
82 cx.notify();
83 }
84
85 pub fn diff(&self) -> &Diff {
87 &self.diff
88 }
89
90 pub fn decisions(&self) -> &[Option<bool>] {
92 &self.decisions
93 }
94
95 pub fn decide(&mut self, hunk: usize, accept: bool, cx: &mut Context<Self>) {
97 if let Some(d) = self.decisions.get_mut(hunk) {
98 *d = Some(accept);
99 cx.emit(HunkDecision { hunk, accept });
100 cx.notify();
101 }
102 }
103
104 pub fn toggle_gap(&mut self, gap: usize, cx: &mut Context<Self>) {
106 if !self.expanded.remove(&gap) {
107 self.expanded.insert(gap);
108 }
109 cx.notify();
110 }
111
112 fn header(&self, cx: &mut Context<Self>) -> impl IntoElement {
113 let (added, removed) = (self.diff.added(), self.diff.removed());
114 let unified = self.diff.unified();
115 let copy = icon_button(SharedString::from(format!("{}-copy", self.id)), IconName::Copy, "Copy the diff").on_click(move |_, _, cx| {
116 cx.write_to_clipboard(ClipboardItem::new_string(unified.clone()));
117 });
118 let all_open = self.expanded.len() == self.diff.gaps().len();
119 let gaps = self.diff.gaps().len();
120 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" })
121 .on_click(cx.listener(move |this, _, _, cx| {
122 if this.expanded.len() == gaps {
123 this.expanded.clear();
124 } else {
125 this.expanded = (0..gaps).collect();
126 }
127 cx.notify();
128 }));
129 h_flex()
130 .w_full()
131 .gap(px(theme::SM))
132 .px(px(theme::SM))
133 .py(px(theme::XS))
134 .bg(color::raised())
135 .border_b_1()
136 .border_color(color::border())
137 .when_some(self.title.clone(), |d, t| d.child(div().min_w_0().truncate().text_sm().font_weight(FontWeight::SEMIBOLD).child(t)))
138 .child(div().text_sm().font_family("monospace").text_color(color::success()).child(format!("+{added}")))
139 .child(div().text_sm().font_family("monospace").text_color(color::danger()).child(format!("−{removed}")))
140 .when(self.diff.json, |d| d.child(div().text_xs().text_color(color::text_muted()).child("JSON, by key")))
141 .child(div().flex_1())
142 .when(gaps > 0, |d| d.child(unfold))
143 .child(copy)
144 }
145
146 fn hunk_header(&self, i: usize, cx: &mut Context<Self>) -> impl IntoElement {
147 let h = &self.diff.hunks[i];
148 let decided = self.decisions.get(i).copied().flatten();
149 let review = self.reviewable.then(|| {
150 let accept = Button::new(SharedString::from(format!("{}-accept-{i}", self.id)))
151 .small()
152 .icon(IconName::Check)
153 .label("Accept")
154 .when(decided == Some(true), |b| b.primary())
155 .when(decided != Some(true), |b| b.outline())
156 .selected(decided == Some(true))
157 .tooltip("Keep this change")
158 .on_click(cx.listener(move |this, _, _, cx| this.decide(i, true, cx)));
159 let reject = Button::new(SharedString::from(format!("{}-reject-{i}", self.id)))
160 .small()
161 .icon(IconName::Close)
162 .label("Reject")
163 .when(decided == Some(false), |b| b.danger())
164 .when(decided != Some(false), |b| b.outline())
165 .selected(decided == Some(false))
166 .tooltip("Drop this change")
167 .on_click(cx.listener(move |this, _, _, cx| this.decide(i, false, cx)));
168 h_flex().gap(px(theme::XS)).child(accept).child(reject)
169 });
170 let status = decided.map(|a| {
171 let (text, c) = if a { ("Accepted", color::success()) } else { ("Rejected", color::danger()) };
172 div().text_xs().font_weight(FontWeight::SEMIBOLD).text_color(c).child(text)
173 });
174 h_flex()
175 .w_full()
176 .min_h(px(theme::TARGET_MIN + theme::XS))
177 .gap(px(theme::SM))
178 .px(px(theme::SM))
179 .bg(color::raised())
180 .border_t_1()
181 .border_b_1()
182 .border_color(color::border())
183 .child(div().flex_1().min_w_0().truncate().text_xs().font_family("monospace").text_color(color::text_muted()).child(h.header()))
184 .children(status)
185 .children(review)
186 }
187
188 fn gap_row(&self, gap: usize, len: usize, cx: &mut Context<Self>) -> impl IntoElement {
189 let label = if len == 1 { "⋯ 1 unchanged line".to_string() } else { format!("⋯ {} unchanged lines", crate::diff::group_digits(len)) };
190 h_flex()
191 .id(SharedString::from(format!("{}-gap-{gap}", self.id)))
192 .w_full()
193 .min_h(px(theme::TARGET_MIN))
194 .px(px(theme::SM))
195 .bg(color::panel())
196 .text_xs()
197 .text_color(color::text_muted())
198 .cursor_pointer()
199 .hover(|d| d.bg(color::hover()))
200 .child(label)
201 .on_click(cx.listener(move |this, _, _, cx| this.toggle_gap(gap, cx)))
202 .test_support()
203 }
204}
205
206fn line_row(l: &DiffLine) -> AnyElement {
208 let (bg, sign, sign_color): (Option<Hsla>, &str, Hsla) = match l.kind {
209 LineKind::Context => (None, " ", color::text_muted()),
210 LineKind::Added => (Some(color::diff_added()), "+", color::success()),
211 LineKind::Removed => (Some(color::diff_removed()), "−", color::danger()),
212 LineKind::Summary => (Some(color::control()), "~", color::text_muted()),
213 };
214 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());
215 h_flex()
216 .w_full()
217 .items_start()
218 .font_family("monospace")
219 .text_xs()
220 .when_some(bg, |d, bg| d.bg(bg))
221 .child(num(l.old))
222 .child(num(l.new))
223 .child(div().flex_none().w(px(16.)).text_center().font_weight(FontWeight::SEMIBOLD).text_color(sign_color).child(sign))
224 .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() }))
225 .into_any_element()
226}
227
228impl Render for DiffView {
229 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
230 let gaps = self.diff.gaps();
231 let mut body: Vec<AnyElement> = Vec::new();
232 if let Some(note) = &self.diff.note {
233 body.push(div().px(px(theme::SM)).py(px(theme::XS)).text_xs().text_color(color::warning()).child(note.clone()).into_any_element());
234 }
235 if self.diff.is_empty() {
236 body.push(div().px(px(theme::SM)).py(px(theme::SM)).text_sm().text_color(color::text_muted()).child("No changes.").into_any_element());
237 } else {
238 let mut gi = 0;
240 for hi in 0..self.diff.hunks.len() {
241 let start = self.diff.hunks[hi].lines.start;
242 while gi < gaps.len() && gaps[gi].end <= start {
243 body.extend(self.gap(gi, &gaps, cx));
244 gi += 1;
245 }
246 body.push(self.hunk_header(hi, cx).into_any_element());
247 body.extend(self.diff.lines[self.diff.hunks[hi].lines.clone()].iter().map(line_row));
248 }
249 while gi < gaps.len() {
250 body.extend(self.gap(gi, &gaps, cx));
251 gi += 1;
252 }
253 }
254 v_flex()
255 .id(self.id.clone())
256 .size_full()
257 .rounded(px(theme::RADIUS_LG))
258 .border_1()
259 .border_color(color::border())
260 .bg(color::panel())
261 .overflow_hidden()
262 .child(self.header(cx))
263 .child(v_flex().id(SharedString::from(format!("{}-body", self.id))).flex_1().min_h_0().w_full().overflow_y_scroll().children(body))
264 }
265}
266
267impl DiffView {
268 fn gap(&self, gi: usize, gaps: &[std::ops::Range<usize>], cx: &mut Context<Self>) -> Vec<AnyElement> {
270 let range = gaps[gi].clone();
271 if self.expanded.contains(&gi) {
272 let mut out = vec![self.gap_row_open(gi, cx).into_any_element()];
273 out.extend(self.diff.lines[range].iter().map(line_row));
274 out
275 } else {
276 vec![self.gap_row(gi, range.len(), cx).into_any_element()]
277 }
278 }
279
280 fn gap_row_open(&self, gap: usize, cx: &mut Context<Self>) -> impl IntoElement {
281 h_flex()
282 .id(SharedString::from(format!("{}-gap-{gap}", self.id)))
283 .w_full()
284 .min_h(px(theme::TARGET_MIN))
285 .px(px(theme::SM))
286 .text_xs()
287 .text_color(color::text_muted())
288 .cursor_pointer()
289 .hover(|d| d.bg(color::hover()))
290 .child("Fold the unchanged lines")
291 .on_click(cx.listener(move |this, _, _, cx| this.toggle_gap(gap, cx)))
292 .test_support()
293 }
294}