Skip to main content

kitchen_ui/widgets/
diff_view.rs

1//! The diff view: a git-style diff of two texts or two JSON documents, with unchanged runs folded
2//! and, for reviewing an agent's proposed change, Accept / Reject per hunk.
3
4use 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/// What the reviewer decided for a hunk ([`DiffView::reviewable`]).
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub struct HunkDecision {
22    /// The hunk ([`Diff::hunks`] index).
23    pub hunk: usize,
24    /// Accepted (`true`) or rejected.
25    pub accept: bool,
26}
27
28/// A git-style diff: added lines on a green tint, removed lines on a red one (the shared
29/// success / danger colours, contrast-checked in both themes), old and new line numbers, a header
30/// per hunk, and the unchanged runs between hunks folded to one line ("⋯ 12 unchanged lines",
31/// a click shows them). Each hunk keeps [`CONTEXT`] unchanged lines around its changes.
32///
33/// [`DiffView::json`] compares JSON by key (sorted keys, long arrays as one summary line: see
34/// [`crate::diff`]). [`DiffView::reviewable`] adds Accept / Reject to each hunk; a choice emits
35/// [`HunkDecision`] and stays shown. The header counts the lines and copies the diff as text.
36pub 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    /// A view of `diff`. `id` names its elements (unique in the window).
49    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    /// The line diff of two texts.
55    pub fn text(id: impl Into<SharedString>, old: &str, new: &str) -> Self {
56        Self::new(id, Diff::text(old, new, CONTEXT))
57    }
58
59    /// Two JSON documents compared by key ([`Diff::json`]): reordered keys are not changes, long
60    /// arrays are one line.
61    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    /// A title in the header (a file name: `session.json`).
66    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
67        self.title = Some(title.into());
68        self
69    }
70
71    /// Accept / Reject buttons on each hunk (an agent's proposed change under review).
72    pub fn reviewable(mut self, reviewable: bool) -> Self {
73        self.reviewable = reviewable;
74        self
75    }
76
77    /// Shows another diff (the decisions and unfolded runs are cleared).
78    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    /// The diff shown.
86    pub fn diff(&self) -> &Diff {
87        &self.diff
88    }
89
90    /// The decision on each hunk so far (`None`: not decided).
91    pub fn decisions(&self) -> &[Option<bool>] {
92        &self.decisions
93    }
94
95    /// Accepts or rejects `hunk` and tells the owner.
96    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    /// Shows (or folds again) the unchanged run [`Diff::gaps`]`[gap]`.
105    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
206/// One line of the diff: old number, new number, sign, text.
207fn 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            // Walk the lines: folded runs and hunks in order
239            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    /// A folded run as its expander, or its lines when shown.
269    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}