1use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll};
2use agent::{Thread, ThreadEvent};
3use agent_settings::AgentSettings;
4use anyhow::Result;
5use buffer_diff::DiffHunkStatus;
6use collections::{HashMap, HashSet};
7use editor::{
8 Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, MultiBufferSnapshot,
9 SelectionEffects, ToPoint,
10 actions::{GoToHunk, GoToPreviousHunk},
11 scroll::Autoscroll,
12};
13use gpui::{
14 Action, Animation, AnimationExt, AnyElement, AnyView, App, AppContext, Empty, Entity,
15 EventEmitter, FocusHandle, Focusable, Global, SharedString, Subscription, Task, Transformation,
16 WeakEntity, Window, percentage, prelude::*,
17};
18
19use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point};
20use language_model::StopReason;
21use multi_buffer::PathKey;
22use project::{Project, ProjectItem, ProjectPath};
23use settings::{Settings, SettingsStore};
24use std::{
25 any::{Any, TypeId},
26 collections::hash_map::Entry,
27 ops::Range,
28 sync::Arc,
29 time::Duration,
30};
31use ui::{IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider};
32use util::ResultExt;
33use workspace::{
34 Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
35 Workspace,
36 item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
37 searchable::SearchableItemHandle,
38};
39use zed_actions::assistant::ToggleFocus;
40
41pub struct AgentDiffPane {
42 multibuffer: Entity<MultiBuffer>,
43 editor: Entity<Editor>,
44 thread: Entity<Thread>,
45 focus_handle: FocusHandle,
46 workspace: WeakEntity<Workspace>,
47 title: SharedString,
48 _subscriptions: Vec<Subscription>,
49}
50
51impl AgentDiffPane {
52 pub fn deploy(
53 thread: Entity<Thread>,
54 workspace: WeakEntity<Workspace>,
55 window: &mut Window,
56 cx: &mut App,
57 ) -> Result<Entity<Self>> {
58 workspace.update(cx, |workspace, cx| {
59 Self::deploy_in_workspace(thread, workspace, window, cx)
60 })
61 }
62
63 pub fn deploy_in_workspace(
64 thread: Entity<Thread>,
65 workspace: &mut Workspace,
66 window: &mut Window,
67 cx: &mut Context<Workspace>,
68 ) -> Entity<Self> {
69 let existing_diff = workspace
70 .items_of_type::<AgentDiffPane>(cx)
71 .find(|diff| diff.read(cx).thread == thread);
72 if let Some(existing_diff) = existing_diff {
73 workspace.activate_item(&existing_diff, true, true, window, cx);
74 existing_diff
75 } else {
76 let agent_diff = cx
77 .new(|cx| AgentDiffPane::new(thread.clone(), workspace.weak_handle(), window, cx));
78 workspace.add_item_to_center(Box::new(agent_diff.clone()), window, cx);
79 agent_diff
80 }
81 }
82
83 pub fn new(
84 thread: Entity<Thread>,
85 workspace: WeakEntity<Workspace>,
86 window: &mut Window,
87 cx: &mut Context<Self>,
88 ) -> Self {
89 let focus_handle = cx.focus_handle();
90 let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
91
92 let project = thread.read(cx).project().clone();
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(diff_hunk_controls(&thread), cx);
99 editor.register_addon(AgentDiffAddon);
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, diff_handle) in changed_buffers {
131 if buffer.read(cx).file().is_none() {
132 continue;
133 }
134
135 let path_key = PathKey::for_buffer(&buffer, cx);
136 paths_to_delete.remove(&path_key);
137
138 let snapshot = buffer.read(cx).snapshot();
139 let diff = diff_handle.read(cx);
140
141 let diff_hunk_ranges = diff
142 .hunks_intersecting_range(
143 language::Anchor::MIN..language::Anchor::MAX,
144 &snapshot,
145 cx,
146 )
147 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
148 .collect::<Vec<_>>();
149
150 let (was_empty, is_excerpt_newly_added) =
151 self.multibuffer.update(cx, |multibuffer, cx| {
152 let was_empty = multibuffer.is_empty();
153 let (_, is_excerpt_newly_added) = multibuffer.set_excerpts_for_path(
154 path_key.clone(),
155 buffer.clone(),
156 diff_hunk_ranges,
157 editor::DEFAULT_MULTIBUFFER_CONTEXT,
158 cx,
159 );
160 multibuffer.add_diff(diff_handle, cx);
161 (was_empty, is_excerpt_newly_added)
162 });
163
164 self.editor.update(cx, |editor, cx| {
165 if was_empty {
166 let first_hunk = editor
167 .diff_hunks_in_ranges(
168 &[editor::Anchor::min()..editor::Anchor::max()],
169 &self.multibuffer.read(cx).read(cx),
170 )
171 .next();
172
173 if let Some(first_hunk) = first_hunk {
174 let first_hunk_start = first_hunk.multi_buffer_range().start;
175 editor.change_selections(Default::default(), window, cx, |selections| {
176 selections.select_anchor_ranges([first_hunk_start..first_hunk_start]);
177 })
178 }
179 }
180
181 if is_excerpt_newly_added
182 && buffer
183 .read(cx)
184 .file()
185 .map_or(false, |file| file.disk_state() == DiskState::Deleted)
186 {
187 editor.fold_buffer(snapshot.text.remote_id(), cx)
188 }
189 });
190 }
191
192 self.multibuffer.update(cx, |multibuffer, cx| {
193 for path in paths_to_delete {
194 multibuffer.remove_excerpts_for_path(path, cx);
195 }
196 });
197
198 if self.multibuffer.read(cx).is_empty()
199 && self
200 .editor
201 .read(cx)
202 .focus_handle(cx)
203 .contains_focused(window, cx)
204 {
205 self.focus_handle.focus(window);
206 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
207 self.editor.update(cx, |editor, cx| {
208 editor.focus_handle(cx).focus(window);
209 });
210 }
211 }
212
213 fn update_title(&mut self, cx: &mut Context<Self>) {
214 let new_title = self.thread.read(cx).summary().unwrap_or("Agent Changes");
215 if new_title != self.title {
216 self.title = new_title;
217 cx.emit(EditorEvent::TitleChanged);
218 }
219 }
220
221 fn handle_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context<Self>) {
222 match event {
223 ThreadEvent::SummaryGenerated => self.update_title(cx),
224 _ => {}
225 }
226 }
227
228 pub fn move_to_path(&self, path_key: PathKey, window: &mut Window, cx: &mut App) {
229 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
230 self.editor.update(cx, |editor, cx| {
231 let first_hunk = editor
232 .diff_hunks_in_ranges(
233 &[position..editor::Anchor::max()],
234 &self.multibuffer.read(cx).read(cx),
235 )
236 .next();
237
238 if let Some(first_hunk) = first_hunk {
239 let first_hunk_start = first_hunk.multi_buffer_range().start;
240 editor.change_selections(Default::default(), window, cx, |selections| {
241 selections.select_anchor_ranges([first_hunk_start..first_hunk_start]);
242 })
243 }
244 });
245 }
246 }
247
248 fn keep(&mut self, _: &Keep, window: &mut Window, cx: &mut Context<Self>) {
249 self.editor.update(cx, |editor, cx| {
250 let snapshot = editor.buffer().read(cx).snapshot(cx);
251 keep_edits_in_selection(editor, &snapshot, &self.thread, window, cx);
252 });
253 }
254
255 fn reject(&mut self, _: &Reject, window: &mut Window, cx: &mut Context<Self>) {
256 self.editor.update(cx, |editor, cx| {
257 let snapshot = editor.buffer().read(cx).snapshot(cx);
258 reject_edits_in_selection(editor, &snapshot, &self.thread, window, cx);
259 });
260 }
261
262 fn reject_all(&mut self, _: &RejectAll, window: &mut Window, cx: &mut Context<Self>) {
263 self.editor.update(cx, |editor, cx| {
264 let snapshot = editor.buffer().read(cx).snapshot(cx);
265 reject_edits_in_ranges(
266 editor,
267 &snapshot,
268 &self.thread,
269 vec![editor::Anchor::min()..editor::Anchor::max()],
270 window,
271 cx,
272 );
273 });
274 }
275
276 fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context<Self>) {
277 self.thread
278 .update(cx, |thread, cx| thread.keep_all_edits(cx));
279 }
280}
281
282fn keep_edits_in_selection(
283 editor: &mut Editor,
284 buffer_snapshot: &MultiBufferSnapshot,
285 thread: &Entity<Thread>,
286 window: &mut Window,
287 cx: &mut Context<Editor>,
288) {
289 let ranges = editor
290 .selections
291 .disjoint_anchor_ranges()
292 .collect::<Vec<_>>();
293
294 keep_edits_in_ranges(editor, buffer_snapshot, &thread, ranges, window, cx)
295}
296
297fn reject_edits_in_selection(
298 editor: &mut Editor,
299 buffer_snapshot: &MultiBufferSnapshot,
300 thread: &Entity<Thread>,
301 window: &mut Window,
302 cx: &mut Context<Editor>,
303) {
304 let ranges = editor
305 .selections
306 .disjoint_anchor_ranges()
307 .collect::<Vec<_>>();
308 reject_edits_in_ranges(editor, buffer_snapshot, &thread, ranges, window, cx)
309}
310
311fn keep_edits_in_ranges(
312 editor: &mut Editor,
313 buffer_snapshot: &MultiBufferSnapshot,
314 thread: &Entity<Thread>,
315 ranges: Vec<Range<editor::Anchor>>,
316 window: &mut Window,
317 cx: &mut Context<Editor>,
318) {
319 let diff_hunks_in_ranges = editor
320 .diff_hunks_in_ranges(&ranges, buffer_snapshot)
321 .collect::<Vec<_>>();
322
323 update_editor_selection(editor, buffer_snapshot, &diff_hunks_in_ranges, window, cx);
324
325 let multibuffer = editor.buffer().clone();
326 for hunk in &diff_hunks_in_ranges {
327 let buffer = multibuffer.read(cx).buffer(hunk.buffer_id);
328 if let Some(buffer) = buffer {
329 thread.update(cx, |thread, cx| {
330 thread.keep_edits_in_range(buffer, hunk.buffer_range.clone(), cx)
331 });
332 }
333 }
334}
335
336fn reject_edits_in_ranges(
337 editor: &mut Editor,
338 buffer_snapshot: &MultiBufferSnapshot,
339 thread: &Entity<Thread>,
340 ranges: Vec<Range<editor::Anchor>>,
341 window: &mut Window,
342 cx: &mut Context<Editor>,
343) {
344 let diff_hunks_in_ranges = editor
345 .diff_hunks_in_ranges(&ranges, buffer_snapshot)
346 .collect::<Vec<_>>();
347
348 update_editor_selection(editor, buffer_snapshot, &diff_hunks_in_ranges, window, cx);
349
350 let multibuffer = editor.buffer().clone();
351
352 let mut ranges_by_buffer = HashMap::default();
353 for hunk in &diff_hunks_in_ranges {
354 let buffer = multibuffer.read(cx).buffer(hunk.buffer_id);
355 if let Some(buffer) = buffer {
356 ranges_by_buffer
357 .entry(buffer.clone())
358 .or_insert_with(Vec::new)
359 .push(hunk.buffer_range.clone());
360 }
361 }
362
363 for (buffer, ranges) in ranges_by_buffer {
364 thread
365 .update(cx, |thread, cx| {
366 thread.reject_edits_in_ranges(buffer, ranges, cx)
367 })
368 .detach_and_log_err(cx);
369 }
370}
371
372fn update_editor_selection(
373 editor: &mut Editor,
374 buffer_snapshot: &MultiBufferSnapshot,
375 diff_hunks: &[multi_buffer::MultiBufferDiffHunk],
376 window: &mut Window,
377 cx: &mut Context<Editor>,
378) {
379 let newest_cursor = editor.selections.newest::<Point>(cx).head();
380
381 if !diff_hunks.iter().any(|hunk| {
382 hunk.row_range
383 .contains(&multi_buffer::MultiBufferRow(newest_cursor.row))
384 }) {
385 return;
386 }
387
388 let target_hunk = {
389 diff_hunks
390 .last()
391 .and_then(|last_kept_hunk| {
392 let last_kept_hunk_end = last_kept_hunk.multi_buffer_range().end;
393 editor
394 .diff_hunks_in_ranges(
395 &[last_kept_hunk_end..editor::Anchor::max()],
396 buffer_snapshot,
397 )
398 .skip(1)
399 .next()
400 })
401 .or_else(|| {
402 let first_kept_hunk = diff_hunks.first()?;
403 let first_kept_hunk_start = first_kept_hunk.multi_buffer_range().start;
404 editor
405 .diff_hunks_in_ranges(
406 &[editor::Anchor::min()..first_kept_hunk_start],
407 buffer_snapshot,
408 )
409 .next()
410 })
411 };
412
413 if let Some(target_hunk) = target_hunk {
414 editor.change_selections(Default::default(), window, cx, |selections| {
415 let next_hunk_start = target_hunk.multi_buffer_range().start;
416 selections.select_anchor_ranges([next_hunk_start..next_hunk_start]);
417 })
418 }
419}
420
421impl EventEmitter<EditorEvent> for AgentDiffPane {}
422
423impl Focusable for AgentDiffPane {
424 fn focus_handle(&self, cx: &App) -> FocusHandle {
425 if self.multibuffer.read(cx).is_empty() {
426 self.focus_handle.clone()
427 } else {
428 self.editor.focus_handle(cx)
429 }
430 }
431}
432
433impl Item for AgentDiffPane {
434 type Event = EditorEvent;
435
436 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
437 Some(Icon::new(IconName::ZedAssistant).color(Color::Muted))
438 }
439
440 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
441 Editor::to_item_events(event, f)
442 }
443
444 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
445 self.editor
446 .update(cx, |editor, cx| editor.deactivated(window, cx));
447 }
448
449 fn navigate(
450 &mut self,
451 data: Box<dyn Any>,
452 window: &mut Window,
453 cx: &mut Context<Self>,
454 ) -> bool {
455 self.editor
456 .update(cx, |editor, cx| editor.navigate(data, window, cx))
457 }
458
459 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
460 Some("Agent Diff".into())
461 }
462
463 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
464 let summary = self.thread.read(cx).summary().unwrap_or("Agent Changes");
465 Label::new(format!("Review: {}", summary))
466 .color(if params.selected {
467 Color::Default
468 } else {
469 Color::Muted
470 })
471 .into_any_element()
472 }
473
474 fn telemetry_event_text(&self) -> Option<&'static str> {
475 Some("Assistant Diff Opened")
476 }
477
478 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
479 Some(Box::new(self.editor.clone()))
480 }
481
482 fn for_each_project_item(
483 &self,
484 cx: &App,
485 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
486 ) {
487 self.editor.for_each_project_item(cx, f)
488 }
489
490 fn is_singleton(&self, _: &App) -> bool {
491 false
492 }
493
494 fn set_nav_history(
495 &mut self,
496 nav_history: ItemNavHistory,
497 _: &mut Window,
498 cx: &mut Context<Self>,
499 ) {
500 self.editor.update(cx, |editor, _| {
501 editor.set_nav_history(Some(nav_history));
502 });
503 }
504
505 fn clone_on_split(
506 &self,
507 _workspace_id: Option<workspace::WorkspaceId>,
508 window: &mut Window,
509 cx: &mut Context<Self>,
510 ) -> Option<Entity<Self>>
511 where
512 Self: Sized,
513 {
514 Some(cx.new(|cx| Self::new(self.thread.clone(), self.workspace.clone(), window, cx)))
515 }
516
517 fn is_dirty(&self, cx: &App) -> bool {
518 self.multibuffer.read(cx).is_dirty(cx)
519 }
520
521 fn has_conflict(&self, cx: &App) -> bool {
522 self.multibuffer.read(cx).has_conflict(cx)
523 }
524
525 fn can_save(&self, _: &App) -> bool {
526 true
527 }
528
529 fn save(
530 &mut self,
531 options: SaveOptions,
532 project: Entity<Project>,
533 window: &mut Window,
534 cx: &mut Context<Self>,
535 ) -> Task<Result<()>> {
536 self.editor.save(options, project, window, cx)
537 }
538
539 fn save_as(
540 &mut self,
541 _: Entity<Project>,
542 _: ProjectPath,
543 _window: &mut Window,
544 _: &mut Context<Self>,
545 ) -> Task<Result<()>> {
546 unreachable!()
547 }
548
549 fn reload(
550 &mut self,
551 project: Entity<Project>,
552 window: &mut Window,
553 cx: &mut Context<Self>,
554 ) -> Task<Result<()>> {
555 self.editor.reload(project, window, cx)
556 }
557
558 fn act_as_type<'a>(
559 &'a self,
560 type_id: TypeId,
561 self_handle: &'a Entity<Self>,
562 _: &'a App,
563 ) -> Option<AnyView> {
564 if type_id == TypeId::of::<Self>() {
565 Some(self_handle.to_any())
566 } else if type_id == TypeId::of::<Editor>() {
567 Some(self.editor.to_any())
568 } else {
569 None
570 }
571 }
572
573 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
574 ToolbarItemLocation::PrimaryLeft
575 }
576
577 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
578 self.editor.breadcrumbs(theme, cx)
579 }
580
581 fn added_to_workspace(
582 &mut self,
583 workspace: &mut Workspace,
584 window: &mut Window,
585 cx: &mut Context<Self>,
586 ) {
587 self.editor.update(cx, |editor, cx| {
588 editor.added_to_workspace(workspace, window, cx)
589 });
590 }
591
592 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
593 "Agent Diff".into()
594 }
595}
596
597impl Render for AgentDiffPane {
598 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
599 let is_empty = self.multibuffer.read(cx).is_empty();
600 let focus_handle = &self.focus_handle;
601
602 div()
603 .track_focus(focus_handle)
604 .key_context(if is_empty { "EmptyPane" } else { "AgentDiff" })
605 .on_action(cx.listener(Self::keep))
606 .on_action(cx.listener(Self::reject))
607 .on_action(cx.listener(Self::reject_all))
608 .on_action(cx.listener(Self::keep_all))
609 .bg(cx.theme().colors().editor_background)
610 .flex()
611 .items_center()
612 .justify_center()
613 .size_full()
614 .when(is_empty, |el| {
615 el.child(
616 v_flex()
617 .items_center()
618 .gap_2()
619 .child("No changes to review")
620 .child(
621 Button::new("continue-iterating", "Continue Iterating")
622 .style(ButtonStyle::Filled)
623 .icon(IconName::ForwardArrow)
624 .icon_position(IconPosition::Start)
625 .icon_size(IconSize::Small)
626 .icon_color(Color::Muted)
627 .full_width()
628 .key_binding(KeyBinding::for_action_in(
629 &ToggleFocus,
630 &focus_handle.clone(),
631 window,
632 cx,
633 ))
634 .on_click(|_event, window, cx| {
635 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
636 }),
637 ),
638 )
639 })
640 .when(!is_empty, |el| el.child(self.editor.clone()))
641 }
642}
643
644fn diff_hunk_controls(thread: &Entity<Thread>) -> editor::RenderDiffHunkControlsFn {
645 let thread = thread.clone();
646
647 Arc::new(
648 move |row,
649 status: &DiffHunkStatus,
650 hunk_range,
651 is_created_file,
652 line_height,
653 editor: &Entity<Editor>,
654 window: &mut Window,
655 cx: &mut App| {
656 {
657 render_diff_hunk_controls(
658 row,
659 status,
660 hunk_range,
661 is_created_file,
662 line_height,
663 &thread,
664 editor,
665 window,
666 cx,
667 )
668 }
669 },
670 )
671}
672
673fn render_diff_hunk_controls(
674 row: u32,
675 _status: &DiffHunkStatus,
676 hunk_range: Range<editor::Anchor>,
677 is_created_file: bool,
678 line_height: Pixels,
679 thread: &Entity<Thread>,
680 editor: &Entity<Editor>,
681 window: &mut Window,
682 cx: &mut App,
683) -> AnyElement {
684 let editor = editor.clone();
685
686 h_flex()
687 .h(line_height)
688 .mr_0p5()
689 .gap_1()
690 .px_0p5()
691 .pb_1()
692 .border_x_1()
693 .border_b_1()
694 .border_color(cx.theme().colors().border)
695 .rounded_b_md()
696 .bg(cx.theme().colors().editor_background)
697 .gap_1()
698 .block_mouse_except_scroll()
699 .shadow_md()
700 .children(vec![
701 Button::new(("reject", row as u64), "Reject")
702 .disabled(is_created_file)
703 .key_binding(
704 KeyBinding::for_action_in(
705 &Reject,
706 &editor.read(cx).focus_handle(cx),
707 window,
708 cx,
709 )
710 .map(|kb| kb.size(rems_from_px(12.))),
711 )
712 .on_click({
713 let editor = editor.clone();
714 let thread = thread.clone();
715 move |_event, window, cx| {
716 editor.update(cx, |editor, cx| {
717 let snapshot = editor.buffer().read(cx).snapshot(cx);
718 reject_edits_in_ranges(
719 editor,
720 &snapshot,
721 &thread,
722 vec![hunk_range.start..hunk_range.start],
723 window,
724 cx,
725 );
726 })
727 }
728 }),
729 Button::new(("keep", row as u64), "Keep")
730 .key_binding(
731 KeyBinding::for_action_in(&Keep, &editor.read(cx).focus_handle(cx), window, cx)
732 .map(|kb| kb.size(rems_from_px(12.))),
733 )
734 .on_click({
735 let editor = editor.clone();
736 let thread = thread.clone();
737 move |_event, window, cx| {
738 editor.update(cx, |editor, cx| {
739 let snapshot = editor.buffer().read(cx).snapshot(cx);
740 keep_edits_in_ranges(
741 editor,
742 &snapshot,
743 &thread,
744 vec![hunk_range.start..hunk_range.start],
745 window,
746 cx,
747 );
748 });
749 }
750 }),
751 ])
752 .when(
753 !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
754 |el| {
755 el.child(
756 IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
757 .shape(IconButtonShape::Square)
758 .icon_size(IconSize::Small)
759 // .disabled(!has_multiple_hunks)
760 .tooltip({
761 let focus_handle = editor.focus_handle(cx);
762 move |window, cx| {
763 Tooltip::for_action_in(
764 "Next Hunk",
765 &GoToHunk,
766 &focus_handle,
767 window,
768 cx,
769 )
770 }
771 })
772 .on_click({
773 let editor = editor.clone();
774 move |_event, window, cx| {
775 editor.update(cx, |editor, cx| {
776 let snapshot = editor.snapshot(window, cx);
777 let position =
778 hunk_range.end.to_point(&snapshot.buffer_snapshot);
779 editor.go_to_hunk_before_or_after_position(
780 &snapshot,
781 position,
782 Direction::Next,
783 window,
784 cx,
785 );
786 editor.expand_selected_diff_hunks(cx);
787 });
788 }
789 }),
790 )
791 .child(
792 IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
793 .shape(IconButtonShape::Square)
794 .icon_size(IconSize::Small)
795 // .disabled(!has_multiple_hunks)
796 .tooltip({
797 let focus_handle = editor.focus_handle(cx);
798 move |window, cx| {
799 Tooltip::for_action_in(
800 "Previous Hunk",
801 &GoToPreviousHunk,
802 &focus_handle,
803 window,
804 cx,
805 )
806 }
807 })
808 .on_click({
809 let editor = editor.clone();
810 move |_event, window, cx| {
811 editor.update(cx, |editor, cx| {
812 let snapshot = editor.snapshot(window, cx);
813 let point =
814 hunk_range.start.to_point(&snapshot.buffer_snapshot);
815 editor.go_to_hunk_before_or_after_position(
816 &snapshot,
817 point,
818 Direction::Prev,
819 window,
820 cx,
821 );
822 editor.expand_selected_diff_hunks(cx);
823 });
824 }
825 }),
826 )
827 },
828 )
829 .into_any_element()
830}
831
832struct AgentDiffAddon;
833
834impl editor::Addon for AgentDiffAddon {
835 fn to_any(&self) -> &dyn std::any::Any {
836 self
837 }
838
839 fn extend_key_context(&self, key_context: &mut gpui::KeyContext, _: &App) {
840 key_context.add("agent_diff");
841 }
842}
843
844pub struct AgentDiffToolbar {
845 active_item: Option<AgentDiffToolbarItem>,
846 _settings_subscription: Subscription,
847}
848
849pub enum AgentDiffToolbarItem {
850 Pane(WeakEntity<AgentDiffPane>),
851 Editor {
852 editor: WeakEntity<Editor>,
853 state: EditorState,
854 _diff_subscription: Subscription,
855 },
856}
857
858impl AgentDiffToolbar {
859 pub fn new(cx: &mut Context<Self>) -> Self {
860 Self {
861 active_item: None,
862 _settings_subscription: cx.observe_global::<SettingsStore>(Self::update_location),
863 }
864 }
865
866 fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
867 let Some(active_item) = self.active_item.as_ref() else {
868 return;
869 };
870
871 match active_item {
872 AgentDiffToolbarItem::Pane(agent_diff) => {
873 if let Some(agent_diff) = agent_diff.upgrade() {
874 agent_diff.focus_handle(cx).focus(window);
875 }
876 }
877 AgentDiffToolbarItem::Editor { editor, .. } => {
878 if let Some(editor) = editor.upgrade() {
879 editor.read(cx).focus_handle(cx).focus(window);
880 }
881 }
882 }
883
884 let action = action.boxed_clone();
885 cx.defer(move |cx| {
886 cx.dispatch_action(action.as_ref());
887 })
888 }
889
890 fn handle_diff_notify(&mut self, agent_diff: Entity<AgentDiff>, cx: &mut Context<Self>) {
891 let Some(AgentDiffToolbarItem::Editor { editor, state, .. }) = self.active_item.as_mut()
892 else {
893 return;
894 };
895
896 *state = agent_diff.read(cx).editor_state(&editor);
897 self.update_location(cx);
898 cx.notify();
899 }
900
901 fn update_location(&mut self, cx: &mut Context<Self>) {
902 let location = self.location(cx);
903 cx.emit(ToolbarItemEvent::ChangeLocation(location));
904 }
905
906 fn location(&self, cx: &App) -> ToolbarItemLocation {
907 if !EditorSettings::get_global(cx).toolbar.agent_review {
908 return ToolbarItemLocation::Hidden;
909 }
910
911 match &self.active_item {
912 None => ToolbarItemLocation::Hidden,
913 Some(AgentDiffToolbarItem::Pane(_)) => ToolbarItemLocation::PrimaryRight,
914 Some(AgentDiffToolbarItem::Editor { state, .. }) => match state {
915 EditorState::Generating | EditorState::Reviewing => {
916 ToolbarItemLocation::PrimaryRight
917 }
918 EditorState::Idle => ToolbarItemLocation::Hidden,
919 },
920 }
921 }
922}
923
924impl EventEmitter<ToolbarItemEvent> for AgentDiffToolbar {}
925
926impl ToolbarItemView for AgentDiffToolbar {
927 fn set_active_pane_item(
928 &mut self,
929 active_pane_item: Option<&dyn ItemHandle>,
930 _: &mut Window,
931 cx: &mut Context<Self>,
932 ) -> ToolbarItemLocation {
933 if let Some(item) = active_pane_item {
934 if let Some(pane) = item.act_as::<AgentDiffPane>(cx) {
935 self.active_item = Some(AgentDiffToolbarItem::Pane(pane.downgrade()));
936 return self.location(cx);
937 }
938
939 if let Some(editor) = item.act_as::<Editor>(cx) {
940 if editor.read(cx).mode().is_full() {
941 let agent_diff = AgentDiff::global(cx);
942
943 self.active_item = Some(AgentDiffToolbarItem::Editor {
944 editor: editor.downgrade(),
945 state: agent_diff.read(cx).editor_state(&editor.downgrade()),
946 _diff_subscription: cx.observe(&agent_diff, Self::handle_diff_notify),
947 });
948
949 return self.location(cx);
950 }
951 }
952 }
953
954 self.active_item = None;
955 return self.location(cx);
956 }
957
958 fn pane_focus_update(
959 &mut self,
960 _pane_focused: bool,
961 _window: &mut Window,
962 _cx: &mut Context<Self>,
963 ) {
964 }
965}
966
967impl Render for AgentDiffToolbar {
968 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
969 let spinner_icon = div()
970 .px_0p5()
971 .id("generating")
972 .tooltip(Tooltip::text("Generating Changes…"))
973 .child(
974 Icon::new(IconName::LoadCircle)
975 .size(IconSize::Small)
976 .color(Color::Accent)
977 .with_animation(
978 "load_circle",
979 Animation::new(Duration::from_secs(3)).repeat(),
980 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
981 ),
982 )
983 .into_any();
984
985 let Some(active_item) = self.active_item.as_ref() else {
986 return Empty.into_any();
987 };
988
989 match active_item {
990 AgentDiffToolbarItem::Editor { editor, state, .. } => {
991 let Some(editor) = editor.upgrade() else {
992 return Empty.into_any();
993 };
994
995 let editor_focus_handle = editor.read(cx).focus_handle(cx);
996
997 let content = match state {
998 EditorState::Idle => return Empty.into_any(),
999 EditorState::Generating => vec![spinner_icon],
1000 EditorState::Reviewing => vec![
1001 h_flex()
1002 .child(
1003 IconButton::new("hunk-up", IconName::ArrowUp)
1004 .icon_size(IconSize::Small)
1005 .tooltip(Tooltip::for_action_title_in(
1006 "Previous Hunk",
1007 &GoToPreviousHunk,
1008 &editor_focus_handle,
1009 ))
1010 .on_click({
1011 let editor_focus_handle = editor_focus_handle.clone();
1012 move |_, window, cx| {
1013 editor_focus_handle.dispatch_action(
1014 &GoToPreviousHunk,
1015 window,
1016 cx,
1017 );
1018 }
1019 }),
1020 )
1021 .child(
1022 IconButton::new("hunk-down", IconName::ArrowDown)
1023 .icon_size(IconSize::Small)
1024 .tooltip(Tooltip::for_action_title_in(
1025 "Next Hunk",
1026 &GoToHunk,
1027 &editor_focus_handle,
1028 ))
1029 .on_click({
1030 let editor_focus_handle = editor_focus_handle.clone();
1031 move |_, window, cx| {
1032 editor_focus_handle
1033 .dispatch_action(&GoToHunk, window, cx);
1034 }
1035 }),
1036 )
1037 .into_any_element(),
1038 vertical_divider().into_any_element(),
1039 h_flex()
1040 .gap_0p5()
1041 .child(
1042 Button::new("reject-all", "Reject All")
1043 .key_binding({
1044 KeyBinding::for_action_in(
1045 &RejectAll,
1046 &editor_focus_handle,
1047 window,
1048 cx,
1049 )
1050 .map(|kb| kb.size(rems_from_px(12.)))
1051 })
1052 .on_click(cx.listener(|this, _, window, cx| {
1053 this.dispatch_action(&RejectAll, window, cx)
1054 })),
1055 )
1056 .child(
1057 Button::new("keep-all", "Keep All")
1058 .key_binding({
1059 KeyBinding::for_action_in(
1060 &KeepAll,
1061 &editor_focus_handle,
1062 window,
1063 cx,
1064 )
1065 .map(|kb| kb.size(rems_from_px(12.)))
1066 })
1067 .on_click(cx.listener(|this, _, window, cx| {
1068 this.dispatch_action(&KeepAll, window, cx)
1069 })),
1070 )
1071 .into_any_element(),
1072 ],
1073 };
1074
1075 h_flex()
1076 .track_focus(&editor_focus_handle)
1077 .size_full()
1078 .px_1()
1079 .mr_1()
1080 .gap_1()
1081 .children(content)
1082 .child(vertical_divider())
1083 .when_some(editor.read(cx).workspace(), |this, _workspace| {
1084 this.child(
1085 IconButton::new("review", IconName::ListTodo)
1086 .icon_size(IconSize::Small)
1087 .tooltip(Tooltip::for_action_title_in(
1088 "Review All Files",
1089 &OpenAgentDiff,
1090 &editor_focus_handle,
1091 ))
1092 .on_click({
1093 cx.listener(move |this, _, window, cx| {
1094 this.dispatch_action(&OpenAgentDiff, window, cx);
1095 })
1096 }),
1097 )
1098 })
1099 .child(vertical_divider())
1100 .on_action({
1101 let editor = editor.clone();
1102 move |_action: &OpenAgentDiff, window, cx| {
1103 AgentDiff::global(cx).update(cx, |agent_diff, cx| {
1104 agent_diff.deploy_pane_from_editor(&editor, window, cx);
1105 });
1106 }
1107 })
1108 .into_any()
1109 }
1110 AgentDiffToolbarItem::Pane(agent_diff) => {
1111 let Some(agent_diff) = agent_diff.upgrade() else {
1112 return Empty.into_any();
1113 };
1114
1115 let has_pending_edit_tool_use = agent_diff
1116 .read(cx)
1117 .thread
1118 .read(cx)
1119 .has_pending_edit_tool_uses();
1120
1121 if has_pending_edit_tool_use {
1122 return div().px_2().child(spinner_icon).into_any();
1123 }
1124
1125 let is_empty = agent_diff.read(cx).multibuffer.read(cx).is_empty();
1126 if is_empty {
1127 return Empty.into_any();
1128 }
1129
1130 let focus_handle = agent_diff.focus_handle(cx);
1131
1132 h_group_xl()
1133 .my_neg_1()
1134 .py_1()
1135 .items_center()
1136 .flex_wrap()
1137 .child(
1138 h_group_sm()
1139 .child(
1140 Button::new("reject-all", "Reject All")
1141 .key_binding({
1142 KeyBinding::for_action_in(
1143 &RejectAll,
1144 &focus_handle,
1145 window,
1146 cx,
1147 )
1148 .map(|kb| kb.size(rems_from_px(12.)))
1149 })
1150 .on_click(cx.listener(|this, _, window, cx| {
1151 this.dispatch_action(&RejectAll, window, cx)
1152 })),
1153 )
1154 .child(
1155 Button::new("keep-all", "Keep All")
1156 .key_binding({
1157 KeyBinding::for_action_in(
1158 &KeepAll,
1159 &focus_handle,
1160 window,
1161 cx,
1162 )
1163 .map(|kb| kb.size(rems_from_px(12.)))
1164 })
1165 .on_click(cx.listener(|this, _, window, cx| {
1166 this.dispatch_action(&KeepAll, window, cx)
1167 })),
1168 ),
1169 )
1170 .into_any()
1171 }
1172 }
1173 }
1174}
1175
1176#[derive(Default)]
1177pub struct AgentDiff {
1178 reviewing_editors: HashMap<WeakEntity<Editor>, EditorState>,
1179 workspace_threads: HashMap<WeakEntity<Workspace>, WorkspaceThread>,
1180}
1181
1182#[derive(Clone, Debug, PartialEq, Eq)]
1183pub enum EditorState {
1184 Idle,
1185 Reviewing,
1186 Generating,
1187}
1188
1189struct WorkspaceThread {
1190 thread: WeakEntity<Thread>,
1191 _thread_subscriptions: [Subscription; 2],
1192 singleton_editors: HashMap<WeakEntity<Buffer>, HashMap<WeakEntity<Editor>, Subscription>>,
1193 _settings_subscription: Subscription,
1194 _workspace_subscription: Option<Subscription>,
1195}
1196
1197struct AgentDiffGlobal(Entity<AgentDiff>);
1198
1199impl Global for AgentDiffGlobal {}
1200
1201impl AgentDiff {
1202 fn global(cx: &mut App) -> Entity<Self> {
1203 cx.try_global::<AgentDiffGlobal>()
1204 .map(|global| global.0.clone())
1205 .unwrap_or_else(|| {
1206 let entity = cx.new(|_cx| Self::default());
1207 let global = AgentDiffGlobal(entity.clone());
1208 cx.set_global(global);
1209 entity.clone()
1210 })
1211 }
1212
1213 pub fn set_active_thread(
1214 workspace: &WeakEntity<Workspace>,
1215 thread: &Entity<Thread>,
1216 window: &mut Window,
1217 cx: &mut App,
1218 ) {
1219 Self::global(cx).update(cx, |this, cx| {
1220 this.register_active_thread_impl(workspace, thread, window, cx);
1221 });
1222 }
1223
1224 fn register_active_thread_impl(
1225 &mut self,
1226 workspace: &WeakEntity<Workspace>,
1227 thread: &Entity<Thread>,
1228 window: &mut Window,
1229 cx: &mut Context<Self>,
1230 ) {
1231 let action_log = thread.read(cx).action_log().clone();
1232
1233 let action_log_subscription = cx.observe_in(&action_log, window, {
1234 let workspace = workspace.clone();
1235 move |this, _action_log, window, cx| {
1236 this.update_reviewing_editors(&workspace, window, cx);
1237 }
1238 });
1239
1240 let thread_subscription = cx.subscribe_in(&thread, window, {
1241 let workspace = workspace.clone();
1242 move |this, _thread, event, window, cx| {
1243 this.handle_thread_event(&workspace, event, window, cx)
1244 }
1245 });
1246
1247 if let Some(workspace_thread) = self.workspace_threads.get_mut(&workspace) {
1248 // replace thread and action log subscription, but keep editors
1249 workspace_thread.thread = thread.downgrade();
1250 workspace_thread._thread_subscriptions = [action_log_subscription, thread_subscription];
1251 self.update_reviewing_editors(&workspace, window, cx);
1252 return;
1253 }
1254
1255 let settings_subscription = cx.observe_global_in::<SettingsStore>(window, {
1256 let workspace = workspace.clone();
1257 let mut was_active = AgentSettings::get_global(cx).single_file_review;
1258 move |this, window, cx| {
1259 let is_active = AgentSettings::get_global(cx).single_file_review;
1260 if was_active != is_active {
1261 was_active = is_active;
1262 this.update_reviewing_editors(&workspace, window, cx);
1263 }
1264 }
1265 });
1266
1267 let workspace_subscription = workspace
1268 .upgrade()
1269 .map(|workspace| cx.subscribe_in(&workspace, window, Self::handle_workspace_event));
1270
1271 self.workspace_threads.insert(
1272 workspace.clone(),
1273 WorkspaceThread {
1274 thread: thread.downgrade(),
1275 _thread_subscriptions: [action_log_subscription, thread_subscription],
1276 singleton_editors: HashMap::default(),
1277 _settings_subscription: settings_subscription,
1278 _workspace_subscription: workspace_subscription,
1279 },
1280 );
1281
1282 let workspace = workspace.clone();
1283 cx.defer_in(window, move |this, window, cx| {
1284 if let Some(workspace) = workspace.upgrade() {
1285 this.register_workspace(workspace, window, cx);
1286 }
1287 });
1288 }
1289
1290 fn register_workspace(
1291 &mut self,
1292 workspace: Entity<Workspace>,
1293 window: &mut Window,
1294 cx: &mut Context<Self>,
1295 ) {
1296 let agent_diff = cx.entity();
1297
1298 let editors = workspace.update(cx, |workspace, cx| {
1299 let agent_diff = agent_diff.clone();
1300
1301 Self::register_review_action::<Keep>(workspace, Self::keep, &agent_diff);
1302 Self::register_review_action::<Reject>(workspace, Self::reject, &agent_diff);
1303 Self::register_review_action::<KeepAll>(workspace, Self::keep_all, &agent_diff);
1304 Self::register_review_action::<RejectAll>(workspace, Self::reject_all, &agent_diff);
1305
1306 workspace.items_of_type(cx).collect::<Vec<_>>()
1307 });
1308
1309 let weak_workspace = workspace.downgrade();
1310
1311 for editor in editors {
1312 if let Some(buffer) = Self::full_editor_buffer(editor.read(cx), cx) {
1313 self.register_editor(weak_workspace.clone(), buffer, editor, window, cx);
1314 };
1315 }
1316
1317 self.update_reviewing_editors(&weak_workspace, window, cx);
1318 }
1319
1320 fn register_review_action<T: Action>(
1321 workspace: &mut Workspace,
1322 review: impl Fn(&Entity<Editor>, &Entity<Thread>, &mut Window, &mut App) -> PostReviewState
1323 + 'static,
1324 this: &Entity<AgentDiff>,
1325 ) {
1326 let this = this.clone();
1327 workspace.register_action(move |workspace, _: &T, window, cx| {
1328 let review = &review;
1329 let task = this.update(cx, |this, cx| {
1330 this.review_in_active_editor(workspace, review, window, cx)
1331 });
1332
1333 if let Some(task) = task {
1334 task.detach_and_log_err(cx);
1335 } else {
1336 cx.propagate();
1337 }
1338 });
1339 }
1340
1341 fn handle_thread_event(
1342 &mut self,
1343 workspace: &WeakEntity<Workspace>,
1344 event: &ThreadEvent,
1345 window: &mut Window,
1346 cx: &mut Context<Self>,
1347 ) {
1348 match event {
1349 ThreadEvent::NewRequest
1350 | ThreadEvent::Stopped(Ok(StopReason::EndTurn))
1351 | ThreadEvent::Stopped(Ok(StopReason::MaxTokens))
1352 | ThreadEvent::Stopped(Ok(StopReason::Refusal))
1353 | ThreadEvent::Stopped(Err(_))
1354 | ThreadEvent::ShowError(_)
1355 | ThreadEvent::CompletionCanceled => {
1356 self.update_reviewing_editors(workspace, window, cx);
1357 }
1358 // intentionally being exhaustive in case we add a variant we should handle
1359 ThreadEvent::Stopped(Ok(StopReason::ToolUse))
1360 | ThreadEvent::StreamedCompletion
1361 | ThreadEvent::ReceivedTextChunk
1362 | ThreadEvent::StreamedAssistantText(_, _)
1363 | ThreadEvent::StreamedAssistantThinking(_, _)
1364 | ThreadEvent::StreamedToolUse { .. }
1365 | ThreadEvent::InvalidToolInput { .. }
1366 | ThreadEvent::MissingToolUse { .. }
1367 | ThreadEvent::MessageAdded(_)
1368 | ThreadEvent::MessageEdited(_)
1369 | ThreadEvent::MessageDeleted(_)
1370 | ThreadEvent::SummaryGenerated
1371 | ThreadEvent::SummaryChanged
1372 | ThreadEvent::UsePendingTools { .. }
1373 | ThreadEvent::ToolFinished { .. }
1374 | ThreadEvent::CheckpointChanged
1375 | ThreadEvent::ToolConfirmationNeeded
1376 | ThreadEvent::ToolUseLimitReached
1377 | ThreadEvent::CancelEditing
1378 | ThreadEvent::RetriesFailed { .. }
1379 | ThreadEvent::ProfileChanged => {}
1380 }
1381 }
1382
1383 fn handle_workspace_event(
1384 &mut self,
1385 workspace: &Entity<Workspace>,
1386 event: &workspace::Event,
1387 window: &mut Window,
1388 cx: &mut Context<Self>,
1389 ) {
1390 match event {
1391 workspace::Event::ItemAdded { item } => {
1392 if let Some(editor) = item.downcast::<Editor>() {
1393 if let Some(buffer) = Self::full_editor_buffer(editor.read(cx), cx) {
1394 self.register_editor(
1395 workspace.downgrade(),
1396 buffer.clone(),
1397 editor,
1398 window,
1399 cx,
1400 );
1401 }
1402 }
1403 }
1404 _ => {}
1405 }
1406 }
1407
1408 fn full_editor_buffer(editor: &Editor, cx: &App) -> Option<WeakEntity<Buffer>> {
1409 if editor.mode().is_full() {
1410 editor
1411 .buffer()
1412 .read(cx)
1413 .as_singleton()
1414 .map(|buffer| buffer.downgrade())
1415 } else {
1416 None
1417 }
1418 }
1419
1420 fn register_editor(
1421 &mut self,
1422 workspace: WeakEntity<Workspace>,
1423 buffer: WeakEntity<Buffer>,
1424 editor: Entity<Editor>,
1425 window: &mut Window,
1426 cx: &mut Context<Self>,
1427 ) {
1428 let Some(workspace_thread) = self.workspace_threads.get_mut(&workspace) else {
1429 return;
1430 };
1431
1432 let weak_editor = editor.downgrade();
1433
1434 workspace_thread
1435 .singleton_editors
1436 .entry(buffer.clone())
1437 .or_default()
1438 .entry(weak_editor.clone())
1439 .or_insert_with(|| {
1440 let workspace = workspace.clone();
1441 cx.observe_release(&editor, move |this, _, _cx| {
1442 let Some(active_thread) = this.workspace_threads.get_mut(&workspace) else {
1443 return;
1444 };
1445
1446 if let Entry::Occupied(mut entry) =
1447 active_thread.singleton_editors.entry(buffer)
1448 {
1449 let set = entry.get_mut();
1450 set.remove(&weak_editor);
1451
1452 if set.is_empty() {
1453 entry.remove();
1454 }
1455 }
1456 })
1457 });
1458
1459 self.update_reviewing_editors(&workspace, window, cx);
1460 }
1461
1462 fn update_reviewing_editors(
1463 &mut self,
1464 workspace: &WeakEntity<Workspace>,
1465 window: &mut Window,
1466 cx: &mut Context<Self>,
1467 ) {
1468 if !AgentSettings::get_global(cx).single_file_review {
1469 for (editor, _) in self.reviewing_editors.drain() {
1470 editor
1471 .update(cx, |editor, cx| {
1472 editor.end_temporary_diff_override(cx);
1473 editor.unregister_addon::<EditorAgentDiffAddon>();
1474 })
1475 .ok();
1476 }
1477 return;
1478 }
1479
1480 let Some(workspace_thread) = self.workspace_threads.get_mut(workspace) else {
1481 return;
1482 };
1483
1484 let Some(thread) = workspace_thread.thread.upgrade() else {
1485 return;
1486 };
1487
1488 let action_log = thread.read(cx).action_log();
1489 let changed_buffers = action_log.read(cx).changed_buffers(cx);
1490
1491 let mut unaffected = self.reviewing_editors.clone();
1492
1493 for (buffer, diff_handle) in changed_buffers {
1494 if buffer.read(cx).file().is_none() {
1495 continue;
1496 }
1497
1498 let Some(buffer_editors) = workspace_thread.singleton_editors.get(&buffer.downgrade())
1499 else {
1500 continue;
1501 };
1502
1503 for (weak_editor, _) in buffer_editors {
1504 let Some(editor) = weak_editor.upgrade() else {
1505 continue;
1506 };
1507
1508 let multibuffer = editor.read(cx).buffer().clone();
1509 multibuffer.update(cx, |multibuffer, cx| {
1510 multibuffer.add_diff(diff_handle.clone(), cx);
1511 });
1512
1513 let new_state = if thread.read(cx).is_generating() {
1514 EditorState::Generating
1515 } else {
1516 EditorState::Reviewing
1517 };
1518
1519 let previous_state = self
1520 .reviewing_editors
1521 .insert(weak_editor.clone(), new_state.clone());
1522
1523 if previous_state.is_none() {
1524 editor.update(cx, |editor, cx| {
1525 editor.start_temporary_diff_override();
1526 editor.set_render_diff_hunk_controls(diff_hunk_controls(&thread), cx);
1527 editor.set_expand_all_diff_hunks(cx);
1528 editor.register_addon(EditorAgentDiffAddon);
1529 });
1530 } else {
1531 unaffected.remove(&weak_editor);
1532 }
1533
1534 if new_state == EditorState::Reviewing && previous_state != Some(new_state) {
1535 // Jump to first hunk when we enter review mode
1536 editor.update(cx, |editor, cx| {
1537 let snapshot = multibuffer.read(cx).snapshot(cx);
1538 if let Some(first_hunk) = snapshot.diff_hunks().next() {
1539 let first_hunk_start = first_hunk.multi_buffer_range().start;
1540
1541 editor.change_selections(
1542 SelectionEffects::scroll(Autoscroll::center()),
1543 window,
1544 cx,
1545 |selections| {
1546 selections.select_ranges([first_hunk_start..first_hunk_start])
1547 },
1548 );
1549 }
1550 });
1551 }
1552 }
1553 }
1554
1555 // Remove editors from this workspace that are no longer under review
1556 for (editor, _) in unaffected {
1557 // Note: We could avoid this check by storing `reviewing_editors` by Workspace,
1558 // but that would add another lookup in `AgentDiff::editor_state`
1559 // which gets called much more frequently.
1560 let in_workspace = editor
1561 .read_with(cx, |editor, _cx| editor.workspace())
1562 .ok()
1563 .flatten()
1564 .map_or(false, |editor_workspace| {
1565 editor_workspace.entity_id() == workspace.entity_id()
1566 });
1567
1568 if in_workspace {
1569 editor
1570 .update(cx, |editor, cx| {
1571 editor.end_temporary_diff_override(cx);
1572 editor.unregister_addon::<EditorAgentDiffAddon>();
1573 })
1574 .ok();
1575 self.reviewing_editors.remove(&editor);
1576 }
1577 }
1578
1579 cx.notify();
1580 }
1581
1582 fn editor_state(&self, editor: &WeakEntity<Editor>) -> EditorState {
1583 self.reviewing_editors
1584 .get(&editor)
1585 .cloned()
1586 .unwrap_or(EditorState::Idle)
1587 }
1588
1589 fn deploy_pane_from_editor(&self, editor: &Entity<Editor>, window: &mut Window, cx: &mut App) {
1590 let Some(workspace) = editor.read(cx).workspace() else {
1591 return;
1592 };
1593
1594 let Some(WorkspaceThread { thread, .. }) =
1595 self.workspace_threads.get(&workspace.downgrade())
1596 else {
1597 return;
1598 };
1599
1600 let Some(thread) = thread.upgrade() else {
1601 return;
1602 };
1603
1604 AgentDiffPane::deploy(thread, workspace.downgrade(), window, cx).log_err();
1605 }
1606
1607 fn keep_all(
1608 editor: &Entity<Editor>,
1609 thread: &Entity<Thread>,
1610 window: &mut Window,
1611 cx: &mut App,
1612 ) -> PostReviewState {
1613 editor.update(cx, |editor, cx| {
1614 let snapshot = editor.buffer().read(cx).snapshot(cx);
1615 keep_edits_in_ranges(
1616 editor,
1617 &snapshot,
1618 thread,
1619 vec![editor::Anchor::min()..editor::Anchor::max()],
1620 window,
1621 cx,
1622 );
1623 });
1624 PostReviewState::AllReviewed
1625 }
1626
1627 fn reject_all(
1628 editor: &Entity<Editor>,
1629 thread: &Entity<Thread>,
1630 window: &mut Window,
1631 cx: &mut App,
1632 ) -> PostReviewState {
1633 editor.update(cx, |editor, cx| {
1634 let snapshot = editor.buffer().read(cx).snapshot(cx);
1635 reject_edits_in_ranges(
1636 editor,
1637 &snapshot,
1638 thread,
1639 vec![editor::Anchor::min()..editor::Anchor::max()],
1640 window,
1641 cx,
1642 );
1643 });
1644 PostReviewState::AllReviewed
1645 }
1646
1647 fn keep(
1648 editor: &Entity<Editor>,
1649 thread: &Entity<Thread>,
1650 window: &mut Window,
1651 cx: &mut App,
1652 ) -> PostReviewState {
1653 editor.update(cx, |editor, cx| {
1654 let snapshot = editor.buffer().read(cx).snapshot(cx);
1655 keep_edits_in_selection(editor, &snapshot, thread, window, cx);
1656 Self::post_review_state(&snapshot)
1657 })
1658 }
1659
1660 fn reject(
1661 editor: &Entity<Editor>,
1662 thread: &Entity<Thread>,
1663 window: &mut Window,
1664 cx: &mut App,
1665 ) -> PostReviewState {
1666 editor.update(cx, |editor, cx| {
1667 let snapshot = editor.buffer().read(cx).snapshot(cx);
1668 reject_edits_in_selection(editor, &snapshot, thread, window, cx);
1669 Self::post_review_state(&snapshot)
1670 })
1671 }
1672
1673 fn post_review_state(snapshot: &MultiBufferSnapshot) -> PostReviewState {
1674 for (i, _) in snapshot.diff_hunks().enumerate() {
1675 if i > 0 {
1676 return PostReviewState::Pending;
1677 }
1678 }
1679 PostReviewState::AllReviewed
1680 }
1681
1682 fn review_in_active_editor(
1683 &mut self,
1684 workspace: &mut Workspace,
1685 review: impl Fn(&Entity<Editor>, &Entity<Thread>, &mut Window, &mut App) -> PostReviewState,
1686 window: &mut Window,
1687 cx: &mut Context<Self>,
1688 ) -> Option<Task<Result<()>>> {
1689 let active_item = workspace.active_item(cx)?;
1690 let editor = active_item.act_as::<Editor>(cx)?;
1691
1692 if !matches!(
1693 self.editor_state(&editor.downgrade()),
1694 EditorState::Reviewing
1695 ) {
1696 return None;
1697 }
1698
1699 let WorkspaceThread { thread, .. } =
1700 self.workspace_threads.get(&workspace.weak_handle())?;
1701
1702 let thread = thread.upgrade()?;
1703
1704 if let PostReviewState::AllReviewed = review(&editor, &thread, window, cx) {
1705 if let Some(curr_buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
1706 let changed_buffers = thread.read(cx).action_log().read(cx).changed_buffers(cx);
1707
1708 let mut keys = changed_buffers.keys().cycle();
1709 keys.find(|k| *k == &curr_buffer);
1710 let next_project_path = keys
1711 .next()
1712 .filter(|k| *k != &curr_buffer)
1713 .and_then(|after| after.read(cx).project_path(cx));
1714
1715 if let Some(path) = next_project_path {
1716 let task = workspace.open_path(path, None, true, window, cx);
1717 let task = cx.spawn(async move |_, _cx| task.await.map(|_| ()));
1718 return Some(task);
1719 }
1720 }
1721 }
1722
1723 return Some(Task::ready(Ok(())));
1724 }
1725}
1726
1727enum PostReviewState {
1728 AllReviewed,
1729 Pending,
1730}
1731
1732pub struct EditorAgentDiffAddon;
1733
1734impl editor::Addon for EditorAgentDiffAddon {
1735 fn to_any(&self) -> &dyn std::any::Any {
1736 self
1737 }
1738
1739 fn extend_key_context(&self, key_context: &mut gpui::KeyContext, _: &App) {
1740 key_context.add("agent_diff");
1741 key_context.add("editor_agent_diff");
1742 }
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747 use super::*;
1748 use crate::Keep;
1749 use agent::thread_store::{self, ThreadStore};
1750 use agent_settings::AgentSettings;
1751 use assistant_tool::ToolWorkingSet;
1752 use editor::EditorSettings;
1753 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
1754 use project::{FakeFs, Project};
1755 use prompt_store::PromptBuilder;
1756 use serde_json::json;
1757 use settings::{Settings, SettingsStore};
1758 use std::sync::Arc;
1759 use theme::ThemeSettings;
1760 use util::path;
1761
1762 #[gpui::test]
1763 async fn test_multibuffer_agent_diff(cx: &mut TestAppContext) {
1764 cx.update(|cx| {
1765 let settings_store = SettingsStore::test(cx);
1766 cx.set_global(settings_store);
1767 language::init(cx);
1768 Project::init_settings(cx);
1769 AgentSettings::register(cx);
1770 prompt_store::init(cx);
1771 thread_store::init(cx);
1772 workspace::init_settings(cx);
1773 ThemeSettings::register(cx);
1774 EditorSettings::register(cx);
1775 language_model::init_settings(cx);
1776 });
1777
1778 let fs = FakeFs::new(cx.executor());
1779 fs.insert_tree(
1780 path!("/test"),
1781 json!({"file1": "abc\ndef\nghi\njkl\nmno\npqr\nstu\nvwx\nyz"}),
1782 )
1783 .await;
1784 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
1785 let buffer_path = project
1786 .read_with(cx, |project, cx| {
1787 project.find_project_path("test/file1", cx)
1788 })
1789 .unwrap();
1790
1791 let prompt_store = None;
1792 let thread_store = cx
1793 .update(|cx| {
1794 ThreadStore::load(
1795 project.clone(),
1796 cx.new(|_| ToolWorkingSet::default()),
1797 prompt_store,
1798 Arc::new(PromptBuilder::new(None).unwrap()),
1799 cx,
1800 )
1801 })
1802 .await
1803 .unwrap();
1804 let thread = thread_store.update(cx, |store, cx| store.create_thread(cx));
1805 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1806
1807 let (workspace, cx) =
1808 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1809 let agent_diff = cx.new_window_entity(|window, cx| {
1810 AgentDiffPane::new(thread.clone(), workspace.downgrade(), window, cx)
1811 });
1812 let editor = agent_diff.read_with(cx, |diff, _cx| diff.editor.clone());
1813
1814 let buffer = project
1815 .update(cx, |project, cx| project.open_buffer(buffer_path, cx))
1816 .await
1817 .unwrap();
1818 cx.update(|_, cx| {
1819 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1820 buffer.update(cx, |buffer, cx| {
1821 buffer
1822 .edit(
1823 [
1824 (Point::new(1, 1)..Point::new(1, 2), "E"),
1825 (Point::new(3, 2)..Point::new(3, 3), "L"),
1826 (Point::new(5, 0)..Point::new(5, 1), "P"),
1827 (Point::new(7, 1)..Point::new(7, 2), "W"),
1828 ],
1829 None,
1830 cx,
1831 )
1832 .unwrap()
1833 });
1834 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1835 });
1836 cx.run_until_parked();
1837
1838 // When opening the assistant diff, the cursor is positioned on the first hunk.
1839 assert_eq!(
1840 editor.read_with(cx, |editor, cx| editor.text(cx)),
1841 "abc\ndef\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nvWx\nyz"
1842 );
1843 assert_eq!(
1844 editor
1845 .update(cx, |editor, cx| editor.selections.newest::<Point>(cx))
1846 .range(),
1847 Point::new(1, 0)..Point::new(1, 0)
1848 );
1849
1850 // After keeping a hunk, the cursor should be positioned on the second hunk.
1851 agent_diff.update_in(cx, |diff, window, cx| diff.keep(&Keep, window, cx));
1852 cx.run_until_parked();
1853 assert_eq!(
1854 editor.read_with(cx, |editor, cx| editor.text(cx)),
1855 "abc\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nvWx\nyz"
1856 );
1857 assert_eq!(
1858 editor
1859 .update(cx, |editor, cx| editor.selections.newest::<Point>(cx))
1860 .range(),
1861 Point::new(3, 0)..Point::new(3, 0)
1862 );
1863
1864 // Rejecting a hunk also moves the cursor to the next hunk, possibly cycling if it's at the end.
1865 editor.update_in(cx, |editor, window, cx| {
1866 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
1867 selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)])
1868 });
1869 });
1870 agent_diff.update_in(cx, |diff, window, cx| {
1871 diff.reject(&crate::Reject, window, cx)
1872 });
1873 cx.run_until_parked();
1874 assert_eq!(
1875 editor.read_with(cx, |editor, cx| editor.text(cx)),
1876 "abc\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nyz"
1877 );
1878 assert_eq!(
1879 editor
1880 .update(cx, |editor, cx| editor.selections.newest::<Point>(cx))
1881 .range(),
1882 Point::new(3, 0)..Point::new(3, 0)
1883 );
1884
1885 // Keeping a range that doesn't intersect the current selection doesn't move it.
1886 agent_diff.update_in(cx, |_diff, window, cx| {
1887 let position = editor
1888 .read(cx)
1889 .buffer()
1890 .read(cx)
1891 .read(cx)
1892 .anchor_before(Point::new(7, 0));
1893 editor.update(cx, |editor, cx| {
1894 let snapshot = editor.buffer().read(cx).snapshot(cx);
1895 keep_edits_in_ranges(
1896 editor,
1897 &snapshot,
1898 &thread,
1899 vec![position..position],
1900 window,
1901 cx,
1902 )
1903 });
1904 });
1905 cx.run_until_parked();
1906 assert_eq!(
1907 editor.read_with(cx, |editor, cx| editor.text(cx)),
1908 "abc\ndEf\nghi\njkl\njkL\nmno\nPqr\nstu\nvwx\nyz"
1909 );
1910 assert_eq!(
1911 editor
1912 .update(cx, |editor, cx| editor.selections.newest::<Point>(cx))
1913 .range(),
1914 Point::new(3, 0)..Point::new(3, 0)
1915 );
1916 }
1917
1918 #[gpui::test]
1919 async fn test_singleton_agent_diff(cx: &mut TestAppContext) {
1920 cx.update(|cx| {
1921 let settings_store = SettingsStore::test(cx);
1922 cx.set_global(settings_store);
1923 language::init(cx);
1924 Project::init_settings(cx);
1925 AgentSettings::register(cx);
1926 prompt_store::init(cx);
1927 thread_store::init(cx);
1928 workspace::init_settings(cx);
1929 ThemeSettings::register(cx);
1930 EditorSettings::register(cx);
1931 language_model::init_settings(cx);
1932 workspace::register_project_item::<Editor>(cx);
1933 });
1934
1935 let fs = FakeFs::new(cx.executor());
1936 fs.insert_tree(
1937 path!("/test"),
1938 json!({"file1": "abc\ndef\nghi\njkl\nmno\npqr\nstu\nvwx\nyz"}),
1939 )
1940 .await;
1941 fs.insert_tree(path!("/test"), json!({"file2": "abc\ndef\nghi"}))
1942 .await;
1943
1944 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
1945 let buffer_path1 = project
1946 .read_with(cx, |project, cx| {
1947 project.find_project_path("test/file1", cx)
1948 })
1949 .unwrap();
1950 let buffer_path2 = project
1951 .read_with(cx, |project, cx| {
1952 project.find_project_path("test/file2", cx)
1953 })
1954 .unwrap();
1955
1956 let prompt_store = None;
1957 let thread_store = cx
1958 .update(|cx| {
1959 ThreadStore::load(
1960 project.clone(),
1961 cx.new(|_| ToolWorkingSet::default()),
1962 prompt_store,
1963 Arc::new(PromptBuilder::new(None).unwrap()),
1964 cx,
1965 )
1966 })
1967 .await
1968 .unwrap();
1969 let thread = thread_store.update(cx, |store, cx| store.create_thread(cx));
1970 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1971
1972 let (workspace, cx) =
1973 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1974
1975 // Add the diff toolbar to the active pane
1976 let diff_toolbar = cx.new_window_entity(|_, cx| AgentDiffToolbar::new(cx));
1977
1978 workspace.update_in(cx, {
1979 let diff_toolbar = diff_toolbar.clone();
1980
1981 move |workspace, window, cx| {
1982 workspace.active_pane().update(cx, |pane, cx| {
1983 pane.toolbar().update(cx, |toolbar, cx| {
1984 toolbar.add_item(diff_toolbar, window, cx);
1985 });
1986 })
1987 }
1988 });
1989
1990 // Set the active thread
1991 cx.update(|window, cx| {
1992 AgentDiff::set_active_thread(&workspace.downgrade(), &thread, window, cx)
1993 });
1994
1995 let buffer1 = project
1996 .update(cx, |project, cx| {
1997 project.open_buffer(buffer_path1.clone(), cx)
1998 })
1999 .await
2000 .unwrap();
2001 let buffer2 = project
2002 .update(cx, |project, cx| {
2003 project.open_buffer(buffer_path2.clone(), cx)
2004 })
2005 .await
2006 .unwrap();
2007
2008 // Open an editor for buffer1
2009 let editor1 = cx.new_window_entity(|window, cx| {
2010 Editor::for_buffer(buffer1.clone(), Some(project.clone()), window, cx)
2011 });
2012
2013 workspace.update_in(cx, |workspace, window, cx| {
2014 workspace.add_item_to_active_pane(Box::new(editor1.clone()), None, true, window, cx);
2015 });
2016 cx.run_until_parked();
2017
2018 // Toolbar knows about the current editor, but it's hidden since there are no changes yet
2019 assert!(diff_toolbar.read_with(cx, |toolbar, _cx| matches!(
2020 toolbar.active_item,
2021 Some(AgentDiffToolbarItem::Editor {
2022 state: EditorState::Idle,
2023 ..
2024 })
2025 )));
2026 assert_eq!(
2027 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2028 ToolbarItemLocation::Hidden
2029 );
2030
2031 // Make changes
2032 cx.update(|_, cx| {
2033 action_log.update(cx, |log, cx| log.buffer_read(buffer1.clone(), cx));
2034 buffer1.update(cx, |buffer, cx| {
2035 buffer
2036 .edit(
2037 [
2038 (Point::new(1, 1)..Point::new(1, 2), "E"),
2039 (Point::new(3, 2)..Point::new(3, 3), "L"),
2040 (Point::new(5, 0)..Point::new(5, 1), "P"),
2041 (Point::new(7, 1)..Point::new(7, 2), "W"),
2042 ],
2043 None,
2044 cx,
2045 )
2046 .unwrap()
2047 });
2048 action_log.update(cx, |log, cx| log.buffer_edited(buffer1.clone(), cx));
2049
2050 action_log.update(cx, |log, cx| log.buffer_read(buffer2.clone(), cx));
2051 buffer2.update(cx, |buffer, cx| {
2052 buffer
2053 .edit(
2054 [
2055 (Point::new(0, 0)..Point::new(0, 1), "A"),
2056 (Point::new(2, 1)..Point::new(2, 2), "H"),
2057 ],
2058 None,
2059 cx,
2060 )
2061 .unwrap();
2062 });
2063 action_log.update(cx, |log, cx| log.buffer_edited(buffer2.clone(), cx));
2064 });
2065 cx.run_until_parked();
2066
2067 // The already opened editor displays the diff and the cursor is at the first hunk
2068 assert_eq!(
2069 editor1.read_with(cx, |editor, cx| editor.text(cx)),
2070 "abc\ndef\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nvWx\nyz"
2071 );
2072 assert_eq!(
2073 editor1
2074 .update(cx, |editor, cx| editor.selections.newest::<Point>(cx))
2075 .range(),
2076 Point::new(1, 0)..Point::new(1, 0)
2077 );
2078
2079 // The toolbar is displayed in the right state
2080 assert_eq!(
2081 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2082 ToolbarItemLocation::PrimaryRight
2083 );
2084 assert!(diff_toolbar.read_with(cx, |toolbar, _cx| matches!(
2085 toolbar.active_item,
2086 Some(AgentDiffToolbarItem::Editor {
2087 state: EditorState::Reviewing,
2088 ..
2089 })
2090 )));
2091
2092 // The toolbar respects its setting
2093 override_toolbar_agent_review_setting(false, cx);
2094 assert_eq!(
2095 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2096 ToolbarItemLocation::Hidden
2097 );
2098 override_toolbar_agent_review_setting(true, cx);
2099 assert_eq!(
2100 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2101 ToolbarItemLocation::PrimaryRight
2102 );
2103
2104 // After keeping a hunk, the cursor should be positioned on the second hunk.
2105 workspace.update(cx, |_, cx| {
2106 cx.dispatch_action(&Keep);
2107 });
2108 cx.run_until_parked();
2109 assert_eq!(
2110 editor1.read_with(cx, |editor, cx| editor.text(cx)),
2111 "abc\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nvWx\nyz"
2112 );
2113 assert_eq!(
2114 editor1
2115 .update(cx, |editor, cx| editor.selections.newest::<Point>(cx))
2116 .range(),
2117 Point::new(3, 0)..Point::new(3, 0)
2118 );
2119
2120 // Rejecting a hunk also moves the cursor to the next hunk, possibly cycling if it's at the end.
2121 editor1.update_in(cx, |editor, window, cx| {
2122 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
2123 selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)])
2124 });
2125 });
2126 workspace.update(cx, |_, cx| {
2127 cx.dispatch_action(&Reject);
2128 });
2129 cx.run_until_parked();
2130 assert_eq!(
2131 editor1.read_with(cx, |editor, cx| editor.text(cx)),
2132 "abc\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nyz"
2133 );
2134 assert_eq!(
2135 editor1
2136 .update(cx, |editor, cx| editor.selections.newest::<Point>(cx))
2137 .range(),
2138 Point::new(3, 0)..Point::new(3, 0)
2139 );
2140
2141 // Keeping a range that doesn't intersect the current selection doesn't move it.
2142 editor1.update_in(cx, |editor, window, cx| {
2143 let buffer = editor.buffer().read(cx);
2144 let position = buffer.read(cx).anchor_before(Point::new(7, 0));
2145 let snapshot = buffer.snapshot(cx);
2146 keep_edits_in_ranges(
2147 editor,
2148 &snapshot,
2149 &thread,
2150 vec![position..position],
2151 window,
2152 cx,
2153 )
2154 });
2155 cx.run_until_parked();
2156 assert_eq!(
2157 editor1.read_with(cx, |editor, cx| editor.text(cx)),
2158 "abc\ndEf\nghi\njkl\njkL\nmno\nPqr\nstu\nvwx\nyz"
2159 );
2160 assert_eq!(
2161 editor1
2162 .update(cx, |editor, cx| editor.selections.newest::<Point>(cx))
2163 .range(),
2164 Point::new(3, 0)..Point::new(3, 0)
2165 );
2166
2167 // Reviewing the last change opens the next changed buffer
2168 workspace
2169 .update_in(cx, |workspace, window, cx| {
2170 AgentDiff::global(cx).update(cx, |agent_diff, cx| {
2171 agent_diff.review_in_active_editor(workspace, AgentDiff::keep, window, cx)
2172 })
2173 })
2174 .unwrap()
2175 .await
2176 .unwrap();
2177
2178 cx.run_until_parked();
2179
2180 let editor2 = workspace.update(cx, |workspace, cx| {
2181 workspace.active_item_as::<Editor>(cx).unwrap()
2182 });
2183
2184 let editor2_path = editor2
2185 .read_with(cx, |editor, cx| editor.project_path(cx))
2186 .unwrap();
2187 assert_eq!(editor2_path, buffer_path2);
2188
2189 assert_eq!(
2190 editor2.read_with(cx, |editor, cx| editor.text(cx)),
2191 "abc\nAbc\ndef\nghi\ngHi"
2192 );
2193 assert_eq!(
2194 editor2
2195 .update(cx, |editor, cx| editor.selections.newest::<Point>(cx))
2196 .range(),
2197 Point::new(0, 0)..Point::new(0, 0)
2198 );
2199
2200 // Editor 1 toolbar is hidden since all changes have been reviewed
2201 workspace.update_in(cx, |workspace, window, cx| {
2202 workspace.activate_item(&editor1, true, true, window, cx)
2203 });
2204
2205 assert!(diff_toolbar.read_with(cx, |toolbar, _cx| matches!(
2206 toolbar.active_item,
2207 Some(AgentDiffToolbarItem::Editor {
2208 state: EditorState::Idle,
2209 ..
2210 })
2211 )));
2212 assert_eq!(
2213 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2214 ToolbarItemLocation::Hidden
2215 );
2216 }
2217
2218 fn override_toolbar_agent_review_setting(active: bool, cx: &mut VisualTestContext) {
2219 cx.update(|_window, cx| {
2220 SettingsStore::update_global(cx, |store, _cx| {
2221 let mut editor_settings = store.get::<EditorSettings>(None).clone();
2222 editor_settings.toolbar.agent_review = active;
2223 store.override_global(editor_settings);
2224 })
2225 });
2226 cx.run_until_parked();
2227 }
2228}