1use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use gpui_kit::assets::IconName;
20use gpui_kit::component::button::{Button, ButtonVariants as _};
21use gpui_kit::component::{Disableable as _, Sizable as _, h_flex, v_flex};
22use gpui_kit::prelude::FluentBuilder as _;
23use gpui_kit::{
24 AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString,
25 StatefulInteractiveElement as _, Styled as _, Window, div, px,
26};
27use kitchen_ui::diff::{ARRAY_SUMMARY, CONTEXT, Diff};
28use kitchen_ui::theme::{self, color};
29use kitchen_ui::widgets::{DiffView, EmptyState, MenuSelect};
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Baseline {
34 LastSave,
36 SessionStart,
38 GitHead,
40}
41
42impl Baseline {
43 pub const ALL: [Baseline; 3] = [Baseline::LastSave, Baseline::SessionStart, Baseline::GitHead];
45
46 pub fn title(self) -> &'static str {
48 match self {
49 Baseline::LastSave => "Since the last save",
50 Baseline::SessionStart => "Since the session started",
51 Baseline::GitHead => "Since the last commit",
52 }
53 }
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
58pub enum ChangesEvent {
59 Changed(usize),
61 Applied {
63 file: PathBuf,
65 accepted: usize,
67 rejected: usize,
69 },
70}
71
72struct Watched {
74 path: PathBuf,
75 name: SharedString,
76 current: String,
77 modified: Option<SystemTime>,
78 saved: String,
79 session: String,
80 head: Option<String>,
81 diff: Diff,
82 view: Entity<DiffView>,
83}
84
85struct Proposal {
87 file: usize,
88 old: String,
90 new: String,
91 view: Entity<DiffView>,
92}
93
94pub struct ChangesPanel {
96 files: Vec<Watched>,
97 baseline: Baseline,
98 proposal: Option<Proposal>,
99 message: Option<(bool, String)>,
100 last_count: usize,
101}
102
103impl EventEmitter<ChangesEvent> for ChangesPanel {}
104
105fn is_json(path: &Path) -> bool {
106 path.extension().is_some_and(|e| e == "json")
107}
108
109fn read(path: &Path) -> (String, Option<SystemTime>) {
110 (std::fs::read_to_string(path).unwrap_or_default(), std::fs::metadata(path).and_then(|m| m.modified()).ok())
111}
112
113pub fn git_head(path: &Path) -> Option<String> {
116 let dir = path.parent()?;
117 let name = path.file_name()?.to_str()?;
118 let out = std::process::Command::new("git").arg("-C").arg(dir).arg("show").arg(format!("HEAD:./{name}")).output().ok()?;
119 out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
120}
121
122fn normalise(text: &str, json: bool) -> String {
125 if json && let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
126 let mut s = serde_json::to_string_pretty(&v).unwrap_or_default();
127 s.push('\n');
128 return s;
129 }
130 text.to_string()
131}
132
133impl ChangesPanel {
134 pub fn new(files: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
137 let files = files
138 .into_iter()
139 .enumerate()
140 .map(|(i, path)| {
141 let (current, modified) = read(&path);
142 let name: SharedString = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default().into();
143 let view = cx.new(|_| DiffView::new(SharedString::from(format!("changes-{i}")), Diff::default()).title(name.clone()));
144 Watched { saved: current.clone(), session: current.clone(), head: None, diff: Diff::default(), current, modified, path, name, view }
145 })
146 .collect();
147 let mut panel = Self { files, baseline: Baseline::LastSave, proposal: None, message: None, last_count: 0 };
148 panel.recompute(cx);
149 cx.spawn(async move |this, cx| {
150 loop {
151 cx.background_executor().timer(std::time::Duration::from_secs(1)).await;
152 if this.update(cx, |p, cx| p.refresh(cx)).is_err() {
153 break;
154 }
155 }
156 })
157 .detach();
158 panel
159 }
160
161 pub fn refresh(&mut self, cx: &mut Context<Self>) {
163 let mut any = false;
164 for f in &mut self.files {
165 let (text, modified) = read(&f.path);
166 if modified != f.modified || text != f.current {
167 f.current = text;
168 f.modified = modified;
169 any = true;
170 }
171 }
172 if any {
173 self.recompute(cx);
174 }
175 }
176
177 fn base<'a>(&self, f: &'a Watched) -> &'a str {
178 match self.baseline {
179 Baseline::LastSave => &f.saved,
180 Baseline::SessionStart => &f.session,
181 Baseline::GitHead => f.head.as_deref().unwrap_or(""),
182 }
183 }
184
185 fn recompute(&mut self, cx: &mut Context<Self>) {
186 for i in 0..self.files.len() {
187 let f = &self.files[i];
188 let base = self.base(f).to_string();
189 let diff = if is_json(&f.path) { Diff::json(&base, &f.current, CONTEXT, ARRAY_SUMMARY) } else { Diff::text(&base, &f.current, CONTEXT) };
190 if diff != self.files[i].diff {
191 self.files[i].view.update(cx, |v, cx| v.set_diff(diff.clone(), cx));
192 self.files[i].diff = diff;
193 }
194 }
195 let count = self.changed_lines();
196 if count != self.last_count {
197 self.last_count = count;
198 cx.emit(ChangesEvent::Changed(count));
199 }
200 cx.notify();
201 }
202
203 pub fn changed_lines(&self) -> usize {
205 self.files.iter().map(|f| f.diff.added() + f.diff.removed()).sum()
206 }
207
208 pub fn diff_of(&self, name: &str) -> Option<&Diff> {
210 self.files.iter().find(|f| f.name == name).map(|f| &f.diff)
211 }
212
213 pub fn baseline(&self) -> Baseline {
215 self.baseline
216 }
217
218 pub fn set_baseline(&mut self, baseline: Baseline, cx: &mut Context<Self>) -> Result<(), String> {
221 if baseline == Baseline::GitHead {
222 for f in &mut self.files {
223 f.head = git_head(&f.path);
224 }
225 if self.files.iter().all(|f| f.head.is_none()) {
226 let e = "The project folder is not a git repository (or its files are not committed)".to_string();
227 self.message = Some((true, e.clone()));
228 cx.notify();
229 return Err(e);
230 }
231 }
232 self.baseline = baseline;
233 self.message = None;
234 self.recompute(cx);
235 Ok(())
236 }
237
238 pub fn mark_saved(&mut self, cx: &mut Context<Self>) {
240 self.refresh(cx);
241 for f in &mut self.files {
242 f.saved = f.current.clone();
243 }
244 self.recompute(cx);
245 }
246
247 pub fn start_session(&mut self, cx: &mut Context<Self>) {
249 self.refresh(cx);
250 for f in &mut self.files {
251 f.session = f.current.clone();
252 }
253 self.recompute(cx);
254 }
255
256 pub fn propose(&mut self, name: &str, new_text: &str, cx: &mut Context<Self>) -> Result<usize, String> {
260 self.refresh(cx);
261 let file = self.files.iter().position(|f| f.name == name).ok_or_else(|| format!("{name} is not watched"))?;
262 let f = &self.files[file];
263 let json = is_json(&f.path);
264 if json {
265 serde_json::from_str::<serde_json::Value>(new_text).map_err(|e| format!("the proposed {name} is not JSON: {e}"))?;
266 }
267 let (old, new) = (normalise(&f.current, json), normalise(new_text, json));
268 let diff = Diff::text(&old, &new, CONTEXT);
269 let hunks = diff.hunks.len();
270 let view = cx.new(|_| DiffView::new("proposal", diff).title(SharedString::from(format!("Proposed: {name}"))).reviewable(true));
271 cx.observe(&view, |_, _, cx| cx.notify()).detach();
272 self.proposal = Some(Proposal { file, old, new, view });
273 self.message = None;
274 cx.notify();
275 Ok(hunks)
276 }
277
278 pub fn proposal(&self) -> Option<Entity<DiffView>> {
280 self.proposal.as_ref().map(|p| p.view.clone())
281 }
282
283 pub fn apply_proposal(&mut self, cx: &mut Context<Self>) -> Result<usize, String> {
286 let p = self.proposal.take().ok_or("no proposal under review")?;
287 let decisions: Vec<bool> = p.view.read(cx).decisions().iter().map(|d| d.unwrap_or(false)).collect();
288 let accepted = decisions.iter().filter(|d| **d).count();
289 let path = self.files[p.file].path.clone();
290 let diff = Diff::text(&p.old, &p.new, CONTEXT);
291 let text = diff.apply(&decisions).ok_or("the proposal cannot be applied")?;
292 if accepted > 0 {
293 std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
294 }
295 let rejected = decisions.len() - accepted;
296 self.message = Some((false, format!("{}: {accepted} change(s) applied, {rejected} rejected", self.files[p.file].name)));
297 cx.emit(ChangesEvent::Applied { file: path, accepted, rejected });
298 self.refresh(cx);
299 cx.notify();
300 Ok(accepted)
301 }
302
303 pub fn discard_proposal(&mut self, cx: &mut Context<Self>) {
305 if let Some(p) = self.proposal.take() {
306 self.message = Some((false, format!("Proposal to {} discarded", self.files[p.file].name)));
307 }
308 cx.notify();
309 }
310}
311
312impl Render for ChangesPanel {
313 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
314 let count = self.changed_lines();
315 let me = cx.entity().downgrade();
316 let baseline = MenuSelect::new(
317 "changes-baseline",
318 Baseline::ALL.iter().map(|b| SharedString::from(b.title())).collect(),
319 Baseline::ALL.iter().position(|b| *b == self.baseline),
320 move |i, _, cx| {
321 let _ = me.update(cx, |p, cx| {
322 let _ = p.set_baseline(Baseline::ALL[i], cx);
323 });
324 },
325 );
326 let badge = div().px(px(theme::SM)).rounded(px(theme::RADIUS_SM)).bg(if count > 0 { color::accent() } else { color::control() }).text_color(if count > 0 { color::on_accent() } else { color::text_muted() }).text_xs().font_weight(FontWeight::SEMIBOLD).child(count.to_string());
327 let header = h_flex()
328 .flex_none()
329 .gap(px(theme::SM))
330 .items_center()
331 .flex_wrap()
332 .child(div().text_base().font_weight(FontWeight::SEMIBOLD).child("Changes"))
333 .child(badge)
334 .child(div().flex_1())
335 .child(div().w(px(220.)).child(baseline));
336 let proposal = self.proposal.as_ref().map(|p| {
337 let decided = p.view.read(cx).decisions().iter().filter(|d| d.is_some()).count();
338 let total = p.view.read(cx).decisions().len();
339 v_flex()
340 .gap(px(theme::SM))
341 .p(px(theme::MD))
342 .rounded(px(theme::RADIUS_LG))
343 .border_1()
344 .border_color(color::accent_mark())
345 .child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(format!("An agent proposes a change to {}", self.files[p.file].name)))
346 .child(div().text_xs().text_color(color::text_muted()).child(format!("{decided} of {total} hunks decided; undecided hunks are not applied.")))
347 .child(p.view.clone())
348 .child(
349 h_flex()
350 .gap(px(theme::SM))
351 .child(Button::new("proposal-apply").small().primary().icon(IconName::Check).label("Apply accepted").disabled(decided == 0).on_click(cx.listener(|p, _, _, cx| {
352 if let Err(e) = p.apply_proposal(cx) {
353 p.message = Some((true, e));
354 }
355 })))
356 .child(Button::new("proposal-discard").small().outline().label("Discard").on_click(cx.listener(|p, _, _, cx| p.discard_proposal(cx)))),
357 )
358 });
359 let changed: Vec<_> = self.files.iter().filter(|f| !f.diff.is_empty()).map(|f| f.view.clone()).collect();
360 let unchanged: Vec<SharedString> = self.files.iter().filter(|f| f.diff.is_empty()).map(|f| f.name.clone()).collect();
361 let body = if changed.is_empty() && self.proposal.is_none() {
362 div().h(px(220.)).child(EmptyState::new(IconName::GitCompare, "No changes", format!("{} match {}.", unchanged.join(", "), self.baseline.title().to_lowercase()))).into_any_element()
363 } else {
364 v_flex()
365 .gap(px(theme::MD))
366 .children(changed)
367 .when(!unchanged.is_empty(), |d| d.child(div().text_xs().text_color(color::text_muted()).child(format!("Unchanged: {}", unchanged.join(", ")))))
368 .into_any_element()
369 };
370 v_flex()
371 .id("changes")
372 .size_full()
373 .overflow_y_scroll()
374 .p(px(theme::LG))
375 .gap(px(theme::MD))
376 .child(header)
377 .children(self.message.clone().map(|(err, m)| div().text_sm().text_color(if err { color::danger() } else { color::text_muted() }).child(m)))
378 .children(proposal)
379 .child(body)
380 }
381}