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}