assistant_diff.rs

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