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