Skip to main content

kitchen_shell/
changes.rs

1//! The Changes panel: the files that say what a project is and how its analysis was done
2//! (`session.json`, `pipeline.json`, `project.json`), watched, and what changed in them shown as
3//! git-style diffs (kitchen-ui's `DiffView`): added lines green, removed red, compared by key
4//! (reordered keys are not changes) with long arrays as one summary line (`cells: 2,539 →
5//! 2,712`).
6//!
7//! - **Compared against** ([`Baseline`]): the last save (the default; the app calls
8//!   [`ChangesPanel::mark_saved`] when it saves), the start of the agent session ("everything
9//!   this session did", [`ChangesPanel::start_session`]), or the last git commit when the
10//!   project folder is a repository.
11//! - **Review**: an agent's proposed change ([`ChangesPanel::propose`]) shows as a diff with
12//!   Accept / Reject per hunk; [`ChangesPanel::apply_proposal`] writes the accepted hunks.
13//! - **Badge**: [`ChangesPanel::changed_lines`] counts the changed lines (the app shows it on the
14//!   panel's icon); [`ChangesEvent`] tells it when the count moves.
15
16use 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/// What the watched files are compared against.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Baseline {
34    /// The files as last saved.
35    LastSave,
36    /// The files when the agent session started.
37    SessionStart,
38    /// The files in the last git commit (when the project is a repository).
39    GitHead,
40}
41
42impl Baseline {
43    /// Every baseline, in the menu's order.
44    pub const ALL: [Baseline; 3] = [Baseline::LastSave, Baseline::SessionStart, Baseline::GitHead];
45
46    /// As the menu says it.
47    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/// What the panel tells its owner.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub enum ChangesEvent {
59    /// The count of changed lines moved (the badge).
60    Changed(usize),
61    /// A proposal was applied to `file` (its accepted hunks written).
62    Applied {
63        /// The file written.
64        file: PathBuf,
65        /// Hunks accepted.
66        accepted: usize,
67        /// Hunks rejected.
68        rejected: usize,
69    },
70}
71
72/// One watched file.
73struct 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
85/// An agent's proposed change to a file, under review.
86struct Proposal {
87    file: usize,
88    /// The file's text and the proposal, both normalised (pretty JSON, sorted keys) when JSON.
89    old: String,
90    new: String,
91    view: Entity<DiffView>,
92}
93
94/// The Changes panel.
95pub 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
113/// The file in the last commit of the repository it is in (`None` when it is not in one, or not
114/// committed).
115pub 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
122/// `text` as pretty JSON with sorted keys when it is JSON, else as is (so a proposal and the file
123/// compare line by line, and the accepted hunks make a valid file).
124fn 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    /// Watches `files` (each read now: that is the last save and the session's start until the
135    /// app says otherwise), checking them for changes every second.
136    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    /// Reads again the files that changed on disk.
162    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    /// Changed lines over every watched file (added and removed): the badge.
204    pub fn changed_lines(&self) -> usize {
205        self.files.iter().map(|f| f.diff.added() + f.diff.removed()).sum()
206    }
207
208    /// The diff of the watched file `name` against the baseline.
209    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    /// What the files are compared against.
214    pub fn baseline(&self) -> Baseline {
215        self.baseline
216    }
217
218    /// Compares against `baseline`; the last commit is refused, with the reason, when no watched
219    /// file is in a git repository.
220    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    /// The files as they are now are the last save (the app calls this when it saves).
239    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    /// The files as they are now are the session's start (an agent session begins).
248    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    /// Shows `new_text` proposed for the watched file `name` as a diff with Accept / Reject per
257    /// hunk. JSON is compared as pretty JSON with sorted keys, so the accepted hunks make a valid
258    /// file.
259    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    /// The proposal's diff view, while one is under review.
279    pub fn proposal(&self) -> Option<Entity<DiffView>> {
280        self.proposal.as_ref().map(|p| p.view.clone())
281    }
282
283    /// Writes the proposal's accepted hunks to its file (undecided hunks count as rejected) and
284    /// closes it; returns how many were accepted.
285    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    /// Drops the proposal without writing anything.
304    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}