assistant_diff.rs

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