assistant_diff.rs

  1use crate::{Thread, ThreadEvent};
  2use anyhow::Result;
  3use buffer_diff::DiffHunkStatus;
  4use collections::HashSet;
  5use editor::{Editor, EditorEvent, MultiBuffer};
  6use futures::future;
  7use gpui::{
  8    prelude::*, AnyElement, AnyView, App, Entity, EventEmitter, FocusHandle, Focusable,
  9    SharedString, Subscription, Task, WeakEntity, Window,
 10};
 11use language::{Capability, OffsetRangeExt};
 12use multi_buffer::PathKey;
 13use project::{Project, ProjectPath};
 14use std::{
 15    any::{Any, TypeId},
 16    ops::Range,
 17    sync::Arc,
 18};
 19use ui::{prelude::*, IconButtonShape};
 20use util::TryFutureExt;
 21use workspace::{
 22    item::{BreadcrumbText, ItemEvent, TabContentParams},
 23    searchable::SearchableItemHandle,
 24    Item, ItemHandle, ItemNavHistory, ToolbarItemLocation, Workspace,
 25};
 26
 27pub struct AssistantDiff {
 28    multibuffer: Entity<MultiBuffer>,
 29    editor: Entity<Editor>,
 30    thread: Entity<Thread>,
 31    focus_handle: FocusHandle,
 32    workspace: WeakEntity<Workspace>,
 33    title: SharedString,
 34    _subscriptions: Vec<Subscription>,
 35}
 36
 37impl AssistantDiff {
 38    pub fn deploy(
 39        thread: Entity<Thread>,
 40        workspace: WeakEntity<Workspace>,
 41        window: &mut Window,
 42        cx: &mut App,
 43    ) -> Result<()> {
 44        let existing_diff = workspace.update(cx, |workspace, cx| {
 45            workspace
 46                .items_of_type::<AssistantDiff>(cx)
 47                .find(|diff| diff.read(cx).thread == thread)
 48        })?;
 49        if let Some(existing_diff) = existing_diff {
 50            workspace.update(cx, |workspace, cx| {
 51                workspace.activate_item(&existing_diff, true, true, window, cx);
 52            })
 53        } else {
 54            let assistant_diff =
 55                cx.new(|cx| AssistantDiff::new(thread.clone(), workspace.clone(), window, cx));
 56            workspace.update(cx, |workspace, cx| {
 57                workspace.add_item_to_center(Box::new(assistant_diff), window, cx);
 58            })
 59        }
 60    }
 61
 62    pub fn new(
 63        thread: Entity<Thread>,
 64        workspace: WeakEntity<Workspace>,
 65        window: &mut Window,
 66        cx: &mut Context<Self>,
 67    ) -> Self {
 68        let focus_handle = cx.focus_handle();
 69        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
 70
 71        let project = thread.read(cx).project().clone();
 72        let render_diff_hunk_controls = Arc::new({
 73            let assistant_diff = cx.entity();
 74            move |row,
 75                  status: &DiffHunkStatus,
 76                  hunk_range,
 77                  is_created_file,
 78                  line_height,
 79                  _editor: &Entity<Editor>,
 80                  cx: &mut App| {
 81                render_diff_hunk_controls(
 82                    row,
 83                    status,
 84                    hunk_range,
 85                    is_created_file,
 86                    line_height,
 87                    &assistant_diff,
 88                    cx,
 89                )
 90            }
 91        });
 92        let editor = cx.new(|cx| {
 93            let mut editor =
 94                Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
 95            editor.disable_inline_diagnostics();
 96            editor.set_expand_all_diff_hunks(cx);
 97            editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx);
 98            editor
 99        });
100
101        let action_log = thread.read(cx).action_log().clone();
102        let mut this = Self {
103            _subscriptions: vec![
104                cx.observe_in(&action_log, window, |this, _action_log, window, cx| {
105                    this.update_excerpts(window, cx)
106                }),
107                cx.subscribe(&thread, |this, _thread, event, cx| {
108                    this.handle_thread_event(event, cx)
109                }),
110            ],
111            title: SharedString::default(),
112            multibuffer,
113            editor,
114            thread,
115            focus_handle,
116            workspace,
117        };
118        this.update_excerpts(window, cx);
119        this.update_title(cx);
120        this
121    }
122
123    fn update_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
124        let thread = self.thread.read(cx);
125        let unreviewed_buffers = thread.action_log().read(cx).unreviewed_buffers();
126        let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
127
128        for (buffer, tracked) in unreviewed_buffers {
129            let Some(file) = buffer.read(cx).file().cloned() else {
130                continue;
131            };
132
133            let path_key = PathKey::namespaced("", file.full_path(cx).into());
134            paths_to_delete.remove(&path_key);
135
136            let snapshot = buffer.read(cx).snapshot();
137            let diff = tracked.diff().read(cx);
138            let diff_hunk_ranges = diff
139                .hunks_intersecting_range(
140                    language::Anchor::MIN..language::Anchor::MAX,
141                    &snapshot,
142                    cx,
143                )
144                .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
145                .collect::<Vec<_>>();
146
147            let was_empty = self.multibuffer.update(cx, |multibuffer, cx| {
148                let was_empty = multibuffer.is_empty();
149                multibuffer.set_excerpts_for_path(
150                    path_key.clone(),
151                    buffer,
152                    diff_hunk_ranges,
153                    editor::DEFAULT_MULTIBUFFER_CONTEXT,
154                    cx,
155                );
156                multibuffer.add_diff(tracked.diff().clone(), cx);
157                was_empty
158            });
159
160            self.editor.update(cx, |editor, cx| {
161                if was_empty {
162                    editor.change_selections(None, window, cx, |selections| {
163                        // TODO select the very beginning (possibly inside a deletion)
164                        selections.select_ranges([0..0])
165                    });
166                }
167            });
168        }
169
170        self.multibuffer.update(cx, |multibuffer, cx| {
171            for path in paths_to_delete {
172                multibuffer.remove_excerpts_for_path(path, cx);
173            }
174        });
175
176        if self.multibuffer.read(cx).is_empty()
177            && self
178                .editor
179                .read(cx)
180                .focus_handle(cx)
181                .contains_focused(window, cx)
182        {
183            self.focus_handle.focus(window);
184        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
185            self.editor.update(cx, |editor, cx| {
186                editor.focus_handle(cx).focus(window);
187            });
188        }
189    }
190
191    fn update_title(&mut self, cx: &mut Context<Self>) {
192        let new_title = self
193            .thread
194            .read(cx)
195            .summary()
196            .unwrap_or("Assistant Changes".into());
197        if new_title != self.title {
198            self.title = new_title;
199            cx.emit(EditorEvent::TitleChanged);
200        }
201    }
202
203    fn handle_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context<Self>) {
204        match event {
205            ThreadEvent::SummaryChanged => self.update_title(cx),
206            _ => {}
207        }
208    }
209
210    fn review_diff_hunks(
211        &mut self,
212        hunk_ranges: Vec<Range<editor::Anchor>>,
213        accept: bool,
214        window: &mut Window,
215        cx: &mut Context<Self>,
216    ) {
217        let snapshot = self.multibuffer.read(cx).snapshot(cx);
218        let diff_hunks_in_ranges = self
219            .editor
220            .read(cx)
221            .diff_hunks_in_ranges(&hunk_ranges, &snapshot)
222            .collect::<Vec<_>>();
223
224        let mut tasks = Vec::new();
225        for hunk in diff_hunks_in_ranges {
226            let buffer = self.multibuffer.read(cx).buffer(hunk.buffer_id);
227            if let Some(buffer) = buffer {
228                let task = self.thread.update(cx, |thread, cx| {
229                    thread.review_edits_in_range(buffer, hunk.buffer_range, accept, cx)
230                });
231                tasks.push(task.log_err());
232            }
233        }
234
235        cx.spawn_in(window, async move |this, cx| {
236            future::join_all(tasks).await;
237            this.update_in(cx, |this, window, cx| this.update_excerpts(window, cx))
238        })
239        .detach_and_log_err(cx);
240    }
241}
242
243impl EventEmitter<EditorEvent> for AssistantDiff {}
244
245impl Focusable for AssistantDiff {
246    fn focus_handle(&self, cx: &App) -> FocusHandle {
247        if self.multibuffer.read(cx).is_empty() {
248            self.focus_handle.clone()
249        } else {
250            self.editor.focus_handle(cx)
251        }
252    }
253}
254
255impl Item for AssistantDiff {
256    type Event = EditorEvent;
257
258    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
259        Some(Icon::new(IconName::ZedAssistant).color(Color::Muted))
260    }
261
262    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
263        Editor::to_item_events(event, f)
264    }
265
266    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
267        self.editor
268            .update(cx, |editor, cx| editor.deactivated(window, cx));
269    }
270
271    fn navigate(
272        &mut self,
273        data: Box<dyn Any>,
274        window: &mut Window,
275        cx: &mut Context<Self>,
276    ) -> bool {
277        self.editor
278            .update(cx, |editor, cx| editor.navigate(data, window, cx))
279    }
280
281    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
282        Some("Project Diff".into())
283    }
284
285    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
286        let summary = self
287            .thread
288            .read(cx)
289            .summary()
290            .unwrap_or("Assistant Changes".into());
291        Label::new(format!("Review: {}", summary))
292            .color(if params.selected {
293                Color::Default
294            } else {
295                Color::Muted
296            })
297            .into_any_element()
298    }
299
300    fn telemetry_event_text(&self) -> Option<&'static str> {
301        Some("Project Diff Opened")
302    }
303
304    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
305        Some(Box::new(self.editor.clone()))
306    }
307
308    fn for_each_project_item(
309        &self,
310        cx: &App,
311        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
312    ) {
313        self.editor.for_each_project_item(cx, f)
314    }
315
316    fn is_singleton(&self, _: &App) -> bool {
317        false
318    }
319
320    fn set_nav_history(
321        &mut self,
322        nav_history: ItemNavHistory,
323        _: &mut Window,
324        cx: &mut Context<Self>,
325    ) {
326        self.editor.update(cx, |editor, _| {
327            editor.set_nav_history(Some(nav_history));
328        });
329    }
330
331    fn clone_on_split(
332        &self,
333        _workspace_id: Option<workspace::WorkspaceId>,
334        window: &mut Window,
335        cx: &mut Context<Self>,
336    ) -> Option<Entity<Self>>
337    where
338        Self: Sized,
339    {
340        Some(cx.new(|cx| Self::new(self.thread.clone(), self.workspace.clone(), window, cx)))
341    }
342
343    fn is_dirty(&self, cx: &App) -> bool {
344        self.multibuffer.read(cx).is_dirty(cx)
345    }
346
347    fn has_conflict(&self, cx: &App) -> bool {
348        self.multibuffer.read(cx).has_conflict(cx)
349    }
350
351    fn can_save(&self, _: &App) -> bool {
352        true
353    }
354
355    fn save(
356        &mut self,
357        format: bool,
358        project: Entity<Project>,
359        window: &mut Window,
360        cx: &mut Context<Self>,
361    ) -> Task<Result<()>> {
362        self.editor.save(format, project, window, cx)
363    }
364
365    fn save_as(
366        &mut self,
367        _: Entity<Project>,
368        _: ProjectPath,
369        _window: &mut Window,
370        _: &mut Context<Self>,
371    ) -> Task<Result<()>> {
372        unreachable!()
373    }
374
375    fn reload(
376        &mut self,
377        project: Entity<Project>,
378        window: &mut Window,
379        cx: &mut Context<Self>,
380    ) -> Task<Result<()>> {
381        self.editor.reload(project, window, cx)
382    }
383
384    fn act_as_type<'a>(
385        &'a self,
386        type_id: TypeId,
387        self_handle: &'a Entity<Self>,
388        _: &'a App,
389    ) -> Option<AnyView> {
390        if type_id == TypeId::of::<Self>() {
391            Some(self_handle.to_any())
392        } else if type_id == TypeId::of::<Editor>() {
393            Some(self.editor.to_any())
394        } else {
395            None
396        }
397    }
398
399    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
400        ToolbarItemLocation::PrimaryLeft
401    }
402
403    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
404        self.editor.breadcrumbs(theme, cx)
405    }
406
407    fn added_to_workspace(
408        &mut self,
409        workspace: &mut Workspace,
410        window: &mut Window,
411        cx: &mut Context<Self>,
412    ) {
413        self.editor.update(cx, |editor, cx| {
414            editor.added_to_workspace(workspace, window, cx)
415        });
416    }
417}
418
419impl Render for AssistantDiff {
420    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
421        let is_empty = self.multibuffer.read(cx).is_empty();
422        div()
423            .track_focus(&self.focus_handle)
424            .key_context(if is_empty {
425                "EmptyPane"
426            } else {
427                "AssistantDiff"
428            })
429            .bg(cx.theme().colors().editor_background)
430            .flex()
431            .items_center()
432            .justify_center()
433            .size_full()
434            .when(is_empty, |el| el.child("No changes to review"))
435            .when(!is_empty, |el| el.child(self.editor.clone()))
436    }
437}
438
439fn render_diff_hunk_controls(
440    row: u32,
441    status: &DiffHunkStatus,
442    hunk_range: Range<editor::Anchor>,
443    is_created_file: bool,
444    line_height: Pixels,
445    assistant_diff: &Entity<AssistantDiff>,
446    cx: &mut App,
447) -> AnyElement {
448    let editor = assistant_diff.read(cx).editor.clone();
449    h_flex()
450        .h(line_height)
451        .mr_1()
452        .gap_1()
453        .px_0p5()
454        .pb_1()
455        .border_x_1()
456        .border_b_1()
457        .border_color(cx.theme().colors().border_variant)
458        .rounded_b_lg()
459        .bg(cx.theme().colors().editor_background)
460        .gap_1()
461        .occlude()
462        .shadow_md()
463        .children(if status.has_secondary_hunk() {
464            vec![
465                Button::new(("stage", row as u64), "Accept")
466                    .alpha(if status.is_pending() { 0.66 } else { 1.0 })
467                    // TODO: add tooltip
468                    // .tooltip({
469                    //     let focus_handle = editor.focus_handle(cx);
470                    //     move |window, cx| {
471                    //         Tooltip::for_action_in(
472                    //             "Stage Hunk",
473                    //             &::git::ToggleStaged,
474                    //             &focus_handle,
475                    //             window,
476                    //             cx,
477                    //         )
478                    //     }
479                    // })
480                    .on_click({
481                        let assistant_diff = assistant_diff.clone();
482                        move |_event, window, cx| {
483                            assistant_diff.update(cx, |diff, cx| {
484                                diff.review_diff_hunks(
485                                    vec![hunk_range.start..hunk_range.start],
486                                    true,
487                                    window,
488                                    cx,
489                                );
490                            });
491                        }
492                    }),
493                Button::new("undo", "Undo")
494                    // TODO: add tooltip
495                    // .tooltip({
496                    //     let focus_handle = editor.focus_handle(cx);
497                    //     move |window, cx| {
498                    //         Tooltip::for_action_in("Undo Hunk", &::git::Undo, &focus_handle, window, cx)
499                    //     }
500                    // })
501                    .on_click({
502                        let _editor = editor.clone();
503                        move |_event, _window, _cx| {
504                            // editor.update(cx, |editor, cx| {
505                            //     let snapshot = editor.snapshot(window, cx);
506                            //     let point = hunk_range.start.to_point(&snapshot.buffer_snapshot);
507                            //     editor.undo_hunks_in_ranges(vec![point..point], window, cx);
508                            // });
509                        }
510                    })
511                    .disabled(is_created_file),
512            ]
513        } else {
514            vec![Button::new(("review", row as u64), "Review")
515                .alpha(if status.is_pending() { 0.66 } else { 1.0 })
516                // TODO: add tooltip
517                // .tooltip({
518                //     let focus_handle = editor.focus_handle(cx);
519                //     move |window, cx| {
520                //         Tooltip::for_action_in(
521                //             "Review",
522                //             &::git::ToggleStaged,
523                //             &focus_handle,
524                //             window,
525                //             cx,
526                //         )
527                //     }
528                // })
529                .on_click({
530                    let assistant_diff = assistant_diff.clone();
531                    move |_event, window, cx| {
532                        assistant_diff.update(cx, |diff, cx| {
533                            diff.review_diff_hunks(
534                                vec![hunk_range.start..hunk_range.start],
535                                false,
536                                window,
537                                cx,
538                            );
539                        });
540                    }
541                })]
542        })
543        .when(
544            !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
545            |el| {
546                el.child(
547                    IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
548                        .shape(IconButtonShape::Square)
549                        .icon_size(IconSize::Small)
550                        // .disabled(!has_multiple_hunks)
551                        // TODO: add tooltip
552                        // .tooltip({
553                        //     let focus_handle = editor.focus_handle(cx);
554                        //     move |window, cx| {
555                        //         Tooltip::for_action_in(
556                        //             "Next Hunk",
557                        //             &GoToHunk,
558                        //             &focus_handle,
559                        //             window,
560                        //             cx,
561                        //         )
562                        //     }
563                        // })
564                        .on_click({
565                            let _editor = editor.clone();
566                            move |_event, _window, _cx| {
567                                // TODO: wire this up
568                                // editor.update(cx, |editor, cx| {
569                                //     let snapshot = editor.snapshot(window, cx);
570                                //     let position =
571                                //         hunk_range.end.to_point(&snapshot.buffer_snapshot);
572                                //     editor.go_to_hunk_before_or_after_position(
573                                //         &snapshot,
574                                //         position,
575                                //         Direction::Next,
576                                //         window,
577                                //         cx,
578                                //     );
579                                //     editor.expand_selected_diff_hunks(cx);
580                                // });
581                            }
582                        }),
583                )
584                .child(
585                    IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
586                        .shape(IconButtonShape::Square)
587                        .icon_size(IconSize::Small)
588                        // .disabled(!has_multiple_hunks)
589                        // TODO: add tooltip
590                        // .tooltip({
591                        //     let focus_handle = editor.focus_handle(cx);
592                        //     move |window, cx| {
593                        //         Tooltip::for_action_in(
594                        //             "Previous Hunk",
595                        //             &GoToPreviousHunk,
596                        //             &focus_handle,
597                        //             window,
598                        //             cx,
599                        //         )
600                        //     }
601                        // })
602                        .on_click({
603                            let _editor = editor.clone();
604                            move |_event, _window, _cx| {
605                                // TODO: wire this up
606                                // editor.update(cx, |editor, cx| {
607                                //     let snapshot = editor.snapshot(window, cx);
608                                //     let point =
609                                //         hunk_range.start.to_point(&snapshot.buffer_snapshot);
610                                //     editor.go_to_hunk_before_or_after_position(
611                                //         &snapshot,
612                                //         point,
613                                //         Direction::Prev,
614                                //         window,
615                                //         cx,
616                                //     );
617                                //     editor.expand_selected_diff_hunks(cx);
618                                // });
619                            }
620                        }),
621                )
622            },
623        )
624        .into_any_element()
625}