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}