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 ) -> Option<Entity<Self>>
585 where
586 Self: Sized,
587 {
588 Some(cx.new(|cx| Self::new(self.thread.clone(), self.workspace.clone(), window, cx)))
589 }
590
591 fn is_dirty(&self, cx: &App) -> bool {
592 self.multibuffer.read(cx).is_dirty(cx)
593 }
594
595 fn has_conflict(&self, cx: &App) -> bool {
596 self.multibuffer.read(cx).has_conflict(cx)
597 }
598
599 fn can_save(&self, _: &App) -> bool {
600 true
601 }
602
603 fn save(
604 &mut self,
605 options: SaveOptions,
606 project: Entity<Project>,
607 window: &mut Window,
608 cx: &mut Context<Self>,
609 ) -> Task<Result<()>> {
610 self.editor.save(options, project, window, cx)
611 }
612
613 fn save_as(
614 &mut self,
615 _: Entity<Project>,
616 _: ProjectPath,
617 _window: &mut Window,
618 _: &mut Context<Self>,
619 ) -> Task<Result<()>> {
620 unreachable!()
621 }
622
623 fn reload(
624 &mut self,
625 project: Entity<Project>,
626 window: &mut Window,
627 cx: &mut Context<Self>,
628 ) -> Task<Result<()>> {
629 self.editor.reload(project, window, cx)
630 }
631
632 fn act_as_type<'a>(
633 &'a self,
634 type_id: TypeId,
635 self_handle: &'a Entity<Self>,
636 _: &'a App,
637 ) -> Option<AnyView> {
638 if type_id == TypeId::of::<Self>() {
639 Some(self_handle.to_any())
640 } else if type_id == TypeId::of::<Editor>() {
641 Some(self.editor.to_any())
642 } else {
643 None
644 }
645 }
646
647 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
648 ToolbarItemLocation::PrimaryLeft
649 }
650
651 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
652 self.editor.breadcrumbs(theme, cx)
653 }
654
655 fn added_to_workspace(
656 &mut self,
657 workspace: &mut Workspace,
658 window: &mut Window,
659 cx: &mut Context<Self>,
660 ) {
661 self.editor.update(cx, |editor, cx| {
662 editor.added_to_workspace(workspace, window, cx)
663 });
664 }
665
666 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
667 "Agent Diff".into()
668 }
669}
670
671impl Render for AgentDiffPane {
672 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
673 let is_empty = self.multibuffer.read(cx).is_empty();
674 let focus_handle = &self.focus_handle;
675
676 div()
677 .track_focus(focus_handle)
678 .key_context(if is_empty { "EmptyPane" } else { "AgentDiff" })
679 .on_action(cx.listener(Self::keep))
680 .on_action(cx.listener(Self::reject))
681 .on_action(cx.listener(Self::reject_all))
682 .on_action(cx.listener(Self::keep_all))
683 .bg(cx.theme().colors().editor_background)
684 .flex()
685 .items_center()
686 .justify_center()
687 .size_full()
688 .when(is_empty, |el| {
689 el.child(
690 v_flex()
691 .items_center()
692 .gap_2()
693 .child("No changes to review")
694 .child(
695 Button::new("continue-iterating", "Continue Iterating")
696 .style(ButtonStyle::Filled)
697 .icon(IconName::ForwardArrow)
698 .icon_position(IconPosition::Start)
699 .icon_size(IconSize::Small)
700 .icon_color(Color::Muted)
701 .full_width()
702 .key_binding(KeyBinding::for_action_in(
703 &ToggleFocus,
704 &focus_handle.clone(),
705 cx,
706 ))
707 .on_click(|_event, window, cx| {
708 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
709 }),
710 ),
711 )
712 })
713 .when(!is_empty, |el| el.child(self.editor.clone()))
714 }
715}
716
717fn diff_hunk_controls(thread: &AgentDiffThread) -> editor::RenderDiffHunkControlsFn {
718 let thread = thread.clone();
719
720 Arc::new(
721 move |row, status, hunk_range, is_created_file, line_height, editor, _, cx| {
722 {
723 render_diff_hunk_controls(
724 row,
725 status,
726 hunk_range,
727 is_created_file,
728 line_height,
729 &thread,
730 editor,
731 cx,
732 )
733 }
734 },
735 )
736}
737
738fn render_diff_hunk_controls(
739 row: u32,
740 _status: &DiffHunkStatus,
741 hunk_range: Range<editor::Anchor>,
742 is_created_file: bool,
743 line_height: Pixels,
744 thread: &AgentDiffThread,
745 editor: &Entity<Editor>,
746 cx: &mut App,
747) -> AnyElement {
748 let editor = editor.clone();
749
750 h_flex()
751 .h(line_height)
752 .mr_0p5()
753 .gap_1()
754 .px_0p5()
755 .pb_1()
756 .border_x_1()
757 .border_b_1()
758 .border_color(cx.theme().colors().border)
759 .rounded_b_md()
760 .bg(cx.theme().colors().editor_background)
761 .gap_1()
762 .block_mouse_except_scroll()
763 .shadow_md()
764 .children(vec![
765 Button::new(("reject", row as u64), "Reject")
766 .disabled(is_created_file)
767 .key_binding(
768 KeyBinding::for_action_in(&Reject, &editor.read(cx).focus_handle(cx), cx)
769 .map(|kb| kb.size(rems_from_px(12.))),
770 )
771 .on_click({
772 let editor = editor.clone();
773 let thread = thread.clone();
774 move |_event, window, cx| {
775 editor.update(cx, |editor, cx| {
776 let snapshot = editor.buffer().read(cx).snapshot(cx);
777 reject_edits_in_ranges(
778 editor,
779 &snapshot,
780 &thread,
781 vec![hunk_range.start..hunk_range.start],
782 window,
783 cx,
784 );
785 })
786 }
787 }),
788 Button::new(("keep", row as u64), "Keep")
789 .key_binding(
790 KeyBinding::for_action_in(&Keep, &editor.read(cx).focus_handle(cx), cx)
791 .map(|kb| kb.size(rems_from_px(12.))),
792 )
793 .on_click({
794 let editor = editor.clone();
795 let thread = thread.clone();
796 move |_event, window, cx| {
797 editor.update(cx, |editor, cx| {
798 let snapshot = editor.buffer().read(cx).snapshot(cx);
799 keep_edits_in_ranges(
800 editor,
801 &snapshot,
802 &thread,
803 vec![hunk_range.start..hunk_range.start],
804 window,
805 cx,
806 );
807 });
808 }
809 }),
810 ])
811 .when(
812 !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
813 |el| {
814 el.child(
815 IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
816 .shape(IconButtonShape::Square)
817 .icon_size(IconSize::Small)
818 // .disabled(!has_multiple_hunks)
819 .tooltip({
820 let focus_handle = editor.focus_handle(cx);
821 move |_window, cx| {
822 Tooltip::for_action_in("Next Hunk", &GoToHunk, &focus_handle, cx)
823 }
824 })
825 .on_click({
826 let editor = editor.clone();
827 move |_event, window, cx| {
828 editor.update(cx, |editor, cx| {
829 let snapshot = editor.snapshot(window, cx);
830 let position =
831 hunk_range.end.to_point(&snapshot.buffer_snapshot());
832 editor.go_to_hunk_before_or_after_position(
833 &snapshot,
834 position,
835 Direction::Next,
836 window,
837 cx,
838 );
839 editor.expand_selected_diff_hunks(cx);
840 });
841 }
842 }),
843 )
844 .child(
845 IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
846 .shape(IconButtonShape::Square)
847 .icon_size(IconSize::Small)
848 // .disabled(!has_multiple_hunks)
849 .tooltip({
850 let focus_handle = editor.focus_handle(cx);
851 move |_window, cx| {
852 Tooltip::for_action_in(
853 "Previous Hunk",
854 &GoToPreviousHunk,
855 &focus_handle,
856 cx,
857 )
858 }
859 })
860 .on_click({
861 let editor = editor.clone();
862 move |_event, window, cx| {
863 editor.update(cx, |editor, cx| {
864 let snapshot = editor.snapshot(window, cx);
865 let point =
866 hunk_range.start.to_point(&snapshot.buffer_snapshot());
867 editor.go_to_hunk_before_or_after_position(
868 &snapshot,
869 point,
870 Direction::Prev,
871 window,
872 cx,
873 );
874 editor.expand_selected_diff_hunks(cx);
875 });
876 }
877 }),
878 )
879 },
880 )
881 .into_any_element()
882}
883
884struct AgentDiffAddon;
885
886impl editor::Addon for AgentDiffAddon {
887 fn to_any(&self) -> &dyn std::any::Any {
888 self
889 }
890
891 fn extend_key_context(&self, key_context: &mut gpui::KeyContext, _: &App) {
892 key_context.add("agent_diff");
893 }
894}
895
896pub struct AgentDiffToolbar {
897 active_item: Option<AgentDiffToolbarItem>,
898 _settings_subscription: Subscription,
899}
900
901pub enum AgentDiffToolbarItem {
902 Pane(WeakEntity<AgentDiffPane>),
903 Editor {
904 editor: WeakEntity<Editor>,
905 state: EditorState,
906 _diff_subscription: Subscription,
907 },
908}
909
910impl AgentDiffToolbar {
911 pub fn new(cx: &mut Context<Self>) -> Self {
912 Self {
913 active_item: None,
914 _settings_subscription: cx.observe_global::<SettingsStore>(Self::update_location),
915 }
916 }
917
918 fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
919 let Some(active_item) = self.active_item.as_ref() else {
920 return;
921 };
922
923 match active_item {
924 AgentDiffToolbarItem::Pane(agent_diff) => {
925 if let Some(agent_diff) = agent_diff.upgrade() {
926 agent_diff.focus_handle(cx).focus(window);
927 }
928 }
929 AgentDiffToolbarItem::Editor { editor, .. } => {
930 if let Some(editor) = editor.upgrade() {
931 editor.read(cx).focus_handle(cx).focus(window);
932 }
933 }
934 }
935
936 let action = action.boxed_clone();
937 cx.defer(move |cx| {
938 cx.dispatch_action(action.as_ref());
939 })
940 }
941
942 fn handle_diff_notify(&mut self, agent_diff: Entity<AgentDiff>, cx: &mut Context<Self>) {
943 let Some(AgentDiffToolbarItem::Editor { editor, state, .. }) = self.active_item.as_mut()
944 else {
945 return;
946 };
947
948 *state = agent_diff.read(cx).editor_state(editor);
949 self.update_location(cx);
950 cx.notify();
951 }
952
953 fn update_location(&mut self, cx: &mut Context<Self>) {
954 let location = self.location(cx);
955 cx.emit(ToolbarItemEvent::ChangeLocation(location));
956 }
957
958 fn location(&self, cx: &App) -> ToolbarItemLocation {
959 if !EditorSettings::get_global(cx).toolbar.agent_review {
960 return ToolbarItemLocation::Hidden;
961 }
962
963 match &self.active_item {
964 None => ToolbarItemLocation::Hidden,
965 Some(AgentDiffToolbarItem::Pane(_)) => ToolbarItemLocation::PrimaryRight,
966 Some(AgentDiffToolbarItem::Editor { state, .. }) => match state {
967 EditorState::Generating | EditorState::Reviewing => {
968 ToolbarItemLocation::PrimaryRight
969 }
970 EditorState::Idle => ToolbarItemLocation::Hidden,
971 },
972 }
973 }
974}
975
976impl EventEmitter<ToolbarItemEvent> for AgentDiffToolbar {}
977
978impl ToolbarItemView for AgentDiffToolbar {
979 fn set_active_pane_item(
980 &mut self,
981 active_pane_item: Option<&dyn ItemHandle>,
982 _: &mut Window,
983 cx: &mut Context<Self>,
984 ) -> ToolbarItemLocation {
985 if let Some(item) = active_pane_item {
986 if let Some(pane) = item.act_as::<AgentDiffPane>(cx) {
987 self.active_item = Some(AgentDiffToolbarItem::Pane(pane.downgrade()));
988 return self.location(cx);
989 }
990
991 if let Some(editor) = item.act_as::<Editor>(cx)
992 && editor.read(cx).mode().is_full()
993 {
994 let agent_diff = AgentDiff::global(cx);
995
996 self.active_item = Some(AgentDiffToolbarItem::Editor {
997 editor: editor.downgrade(),
998 state: agent_diff.read(cx).editor_state(&editor.downgrade()),
999 _diff_subscription: cx.observe(&agent_diff, Self::handle_diff_notify),
1000 });
1001
1002 return self.location(cx);
1003 }
1004 }
1005
1006 self.active_item = None;
1007 self.location(cx)
1008 }
1009
1010 fn pane_focus_update(
1011 &mut self,
1012 _pane_focused: bool,
1013 _window: &mut Window,
1014 _cx: &mut Context<Self>,
1015 ) {
1016 }
1017}
1018
1019impl Render for AgentDiffToolbar {
1020 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1021 let spinner_icon = div()
1022 .px_0p5()
1023 .id("generating")
1024 .tooltip(Tooltip::text("Generating Changes…"))
1025 .child(
1026 Icon::new(IconName::LoadCircle)
1027 .size(IconSize::Small)
1028 .color(Color::Accent)
1029 .with_rotate_animation(3),
1030 )
1031 .into_any();
1032
1033 let Some(active_item) = self.active_item.as_ref() else {
1034 return Empty.into_any();
1035 };
1036
1037 match active_item {
1038 AgentDiffToolbarItem::Editor { editor, state, .. } => {
1039 let Some(editor) = editor.upgrade() else {
1040 return Empty.into_any();
1041 };
1042
1043 let editor_focus_handle = editor.read(cx).focus_handle(cx);
1044
1045 let content = match state {
1046 EditorState::Idle => return Empty.into_any(),
1047 EditorState::Generating => vec![spinner_icon],
1048 EditorState::Reviewing => vec![
1049 h_flex()
1050 .child(
1051 IconButton::new("hunk-up", IconName::ArrowUp)
1052 .icon_size(IconSize::Small)
1053 .tooltip(Tooltip::for_action_title_in(
1054 "Previous Hunk",
1055 &GoToPreviousHunk,
1056 &editor_focus_handle,
1057 ))
1058 .on_click({
1059 let editor_focus_handle = editor_focus_handle.clone();
1060 move |_, window, cx| {
1061 editor_focus_handle.dispatch_action(
1062 &GoToPreviousHunk,
1063 window,
1064 cx,
1065 );
1066 }
1067 }),
1068 )
1069 .child(
1070 IconButton::new("hunk-down", IconName::ArrowDown)
1071 .icon_size(IconSize::Small)
1072 .tooltip(Tooltip::for_action_title_in(
1073 "Next Hunk",
1074 &GoToHunk,
1075 &editor_focus_handle,
1076 ))
1077 .on_click({
1078 let editor_focus_handle = editor_focus_handle.clone();
1079 move |_, window, cx| {
1080 editor_focus_handle
1081 .dispatch_action(&GoToHunk, window, cx);
1082 }
1083 }),
1084 )
1085 .into_any_element(),
1086 vertical_divider().into_any_element(),
1087 h_flex()
1088 .gap_0p5()
1089 .child(
1090 Button::new("reject-all", "Reject All")
1091 .key_binding({
1092 KeyBinding::for_action_in(
1093 &RejectAll,
1094 &editor_focus_handle,
1095 cx,
1096 )
1097 .map(|kb| kb.size(rems_from_px(12.)))
1098 })
1099 .on_click(cx.listener(|this, _, window, cx| {
1100 this.dispatch_action(&RejectAll, window, cx)
1101 })),
1102 )
1103 .child(
1104 Button::new("keep-all", "Keep All")
1105 .key_binding({
1106 KeyBinding::for_action_in(
1107 &KeepAll,
1108 &editor_focus_handle,
1109 cx,
1110 )
1111 .map(|kb| kb.size(rems_from_px(12.)))
1112 })
1113 .on_click(cx.listener(|this, _, window, cx| {
1114 this.dispatch_action(&KeepAll, window, cx)
1115 })),
1116 )
1117 .into_any_element(),
1118 ],
1119 };
1120
1121 h_flex()
1122 .track_focus(&editor_focus_handle)
1123 .size_full()
1124 .px_1()
1125 .mr_1()
1126 .gap_1()
1127 .children(content)
1128 .child(vertical_divider())
1129 .when_some(editor.read(cx).workspace(), |this, _workspace| {
1130 this.child(
1131 IconButton::new("review", IconName::ListTodo)
1132 .icon_size(IconSize::Small)
1133 .tooltip(Tooltip::for_action_title_in(
1134 "Review All Files",
1135 &OpenAgentDiff,
1136 &editor_focus_handle,
1137 ))
1138 .on_click({
1139 cx.listener(move |this, _, window, cx| {
1140 this.dispatch_action(&OpenAgentDiff, window, cx);
1141 })
1142 }),
1143 )
1144 })
1145 .child(vertical_divider())
1146 .on_action({
1147 let editor = editor.clone();
1148 move |_action: &OpenAgentDiff, window, cx| {
1149 AgentDiff::global(cx).update(cx, |agent_diff, cx| {
1150 agent_diff.deploy_pane_from_editor(&editor, window, cx);
1151 });
1152 }
1153 })
1154 .into_any()
1155 }
1156 AgentDiffToolbarItem::Pane(agent_diff) => {
1157 let Some(agent_diff) = agent_diff.upgrade() else {
1158 return Empty.into_any();
1159 };
1160
1161 let has_pending_edit_tool_use =
1162 agent_diff.read(cx).thread.has_pending_edit_tool_uses(cx);
1163
1164 if has_pending_edit_tool_use {
1165 return div().px_2().child(spinner_icon).into_any();
1166 }
1167
1168 let is_empty = agent_diff.read(cx).multibuffer.read(cx).is_empty();
1169 if is_empty {
1170 return Empty.into_any();
1171 }
1172
1173 let focus_handle = agent_diff.focus_handle(cx);
1174
1175 h_group_xl()
1176 .my_neg_1()
1177 .py_1()
1178 .items_center()
1179 .flex_wrap()
1180 .child(
1181 h_group_sm()
1182 .child(
1183 Button::new("reject-all", "Reject All")
1184 .key_binding({
1185 KeyBinding::for_action_in(&RejectAll, &focus_handle, cx)
1186 .map(|kb| kb.size(rems_from_px(12.)))
1187 })
1188 .on_click(cx.listener(|this, _, window, cx| {
1189 this.dispatch_action(&RejectAll, window, cx)
1190 })),
1191 )
1192 .child(
1193 Button::new("keep-all", "Keep All")
1194 .key_binding({
1195 KeyBinding::for_action_in(&KeepAll, &focus_handle, cx)
1196 .map(|kb| kb.size(rems_from_px(12.)))
1197 })
1198 .on_click(cx.listener(|this, _, window, cx| {
1199 this.dispatch_action(&KeepAll, window, cx)
1200 })),
1201 ),
1202 )
1203 .into_any()
1204 }
1205 }
1206 }
1207}
1208
1209#[derive(Default)]
1210pub struct AgentDiff {
1211 reviewing_editors: HashMap<WeakEntity<Editor>, EditorState>,
1212 workspace_threads: HashMap<WeakEntity<Workspace>, WorkspaceThread>,
1213}
1214
1215#[derive(Clone, Debug, PartialEq, Eq)]
1216pub enum EditorState {
1217 Idle,
1218 Reviewing,
1219 Generating,
1220}
1221
1222struct WorkspaceThread {
1223 thread: WeakAgentDiffThread,
1224 _thread_subscriptions: (Subscription, Subscription),
1225 singleton_editors: HashMap<WeakEntity<Buffer>, HashMap<WeakEntity<Editor>, Subscription>>,
1226 _settings_subscription: Subscription,
1227 _workspace_subscription: Option<Subscription>,
1228}
1229
1230struct AgentDiffGlobal(Entity<AgentDiff>);
1231
1232impl Global for AgentDiffGlobal {}
1233
1234impl AgentDiff {
1235 fn global(cx: &mut App) -> Entity<Self> {
1236 cx.try_global::<AgentDiffGlobal>()
1237 .map(|global| global.0.clone())
1238 .unwrap_or_else(|| {
1239 let entity = cx.new(|_cx| Self::default());
1240 let global = AgentDiffGlobal(entity.clone());
1241 cx.set_global(global);
1242 entity
1243 })
1244 }
1245
1246 pub fn set_active_thread(
1247 workspace: &WeakEntity<Workspace>,
1248 thread: impl Into<AgentDiffThread>,
1249 window: &mut Window,
1250 cx: &mut App,
1251 ) {
1252 Self::global(cx).update(cx, |this, cx| {
1253 this.register_active_thread_impl(workspace, thread.into(), window, cx);
1254 });
1255 }
1256
1257 fn register_active_thread_impl(
1258 &mut self,
1259 workspace: &WeakEntity<Workspace>,
1260 thread: AgentDiffThread,
1261 window: &mut Window,
1262 cx: &mut Context<Self>,
1263 ) {
1264 let action_log = thread.action_log(cx);
1265
1266 let action_log_subscription = cx.observe_in(&action_log, window, {
1267 let workspace = workspace.clone();
1268 move |this, _action_log, window, cx| {
1269 this.update_reviewing_editors(&workspace, window, cx);
1270 }
1271 });
1272
1273 let thread_subscription = match &thread {
1274 AgentDiffThread::AcpThread(thread) => cx.subscribe_in(thread, window, {
1275 let workspace = workspace.clone();
1276 move |this, thread, event, window, cx| {
1277 this.handle_acp_thread_event(&workspace, thread, event, window, cx)
1278 }
1279 }),
1280 };
1281
1282 if let Some(workspace_thread) = self.workspace_threads.get_mut(workspace) {
1283 // replace thread and action log subscription, but keep editors
1284 workspace_thread.thread = thread.downgrade();
1285 workspace_thread._thread_subscriptions = (action_log_subscription, thread_subscription);
1286 self.update_reviewing_editors(workspace, window, cx);
1287 return;
1288 }
1289
1290 let settings_subscription = cx.observe_global_in::<SettingsStore>(window, {
1291 let workspace = workspace.clone();
1292 let mut was_active = AgentSettings::get_global(cx).single_file_review;
1293 move |this, window, cx| {
1294 let is_active = AgentSettings::get_global(cx).single_file_review;
1295 if was_active != is_active {
1296 was_active = is_active;
1297 this.update_reviewing_editors(&workspace, window, cx);
1298 }
1299 }
1300 });
1301
1302 let workspace_subscription = workspace
1303 .upgrade()
1304 .map(|workspace| cx.subscribe_in(&workspace, window, Self::handle_workspace_event));
1305
1306 self.workspace_threads.insert(
1307 workspace.clone(),
1308 WorkspaceThread {
1309 thread: thread.downgrade(),
1310 _thread_subscriptions: (action_log_subscription, thread_subscription),
1311 singleton_editors: HashMap::default(),
1312 _settings_subscription: settings_subscription,
1313 _workspace_subscription: workspace_subscription,
1314 },
1315 );
1316
1317 let workspace = workspace.clone();
1318 cx.defer_in(window, move |this, window, cx| {
1319 if let Some(workspace) = workspace.upgrade() {
1320 this.register_workspace(workspace, window, cx);
1321 }
1322 });
1323 }
1324
1325 fn register_workspace(
1326 &mut self,
1327 workspace: Entity<Workspace>,
1328 window: &mut Window,
1329 cx: &mut Context<Self>,
1330 ) {
1331 let agent_diff = cx.entity();
1332
1333 let editors = workspace.update(cx, |workspace, cx| {
1334 let agent_diff = agent_diff.clone();
1335
1336 Self::register_review_action::<Keep>(workspace, Self::keep, &agent_diff);
1337 Self::register_review_action::<Reject>(workspace, Self::reject, &agent_diff);
1338 Self::register_review_action::<KeepAll>(workspace, Self::keep_all, &agent_diff);
1339 Self::register_review_action::<RejectAll>(workspace, Self::reject_all, &agent_diff);
1340
1341 workspace.items_of_type(cx).collect::<Vec<_>>()
1342 });
1343
1344 let weak_workspace = workspace.downgrade();
1345
1346 for editor in editors {
1347 if let Some(buffer) = Self::full_editor_buffer(editor.read(cx), cx) {
1348 self.register_editor(weak_workspace.clone(), buffer, editor, window, cx);
1349 };
1350 }
1351
1352 self.update_reviewing_editors(&weak_workspace, window, cx);
1353 }
1354
1355 fn register_review_action<T: Action>(
1356 workspace: &mut Workspace,
1357 review: impl Fn(&Entity<Editor>, &AgentDiffThread, &mut Window, &mut App) -> PostReviewState
1358 + 'static,
1359 this: &Entity<AgentDiff>,
1360 ) {
1361 let this = this.clone();
1362 workspace.register_action(move |workspace, _: &T, window, cx| {
1363 let review = &review;
1364 let task = this.update(cx, |this, cx| {
1365 this.review_in_active_editor(workspace, review, window, cx)
1366 });
1367
1368 if let Some(task) = task {
1369 task.detach_and_log_err(cx);
1370 } else {
1371 cx.propagate();
1372 }
1373 });
1374 }
1375
1376 fn handle_acp_thread_event(
1377 &mut self,
1378 workspace: &WeakEntity<Workspace>,
1379 thread: &Entity<AcpThread>,
1380 event: &AcpThreadEvent,
1381 window: &mut Window,
1382 cx: &mut Context<Self>,
1383 ) {
1384 match event {
1385 AcpThreadEvent::NewEntry => {
1386 if thread
1387 .read(cx)
1388 .entries()
1389 .last()
1390 .is_some_and(|entry| entry.diffs().next().is_some())
1391 {
1392 self.update_reviewing_editors(workspace, window, cx);
1393 }
1394 }
1395 AcpThreadEvent::EntryUpdated(ix) => {
1396 if thread
1397 .read(cx)
1398 .entries()
1399 .get(*ix)
1400 .is_some_and(|entry| entry.diffs().next().is_some())
1401 {
1402 self.update_reviewing_editors(workspace, window, cx);
1403 }
1404 }
1405 AcpThreadEvent::Stopped
1406 | AcpThreadEvent::Error
1407 | AcpThreadEvent::LoadError(_)
1408 | AcpThreadEvent::Refusal => {
1409 self.update_reviewing_editors(workspace, window, cx);
1410 }
1411 AcpThreadEvent::TitleUpdated
1412 | AcpThreadEvent::TokenUsageUpdated
1413 | AcpThreadEvent::EntriesRemoved(_)
1414 | AcpThreadEvent::ToolAuthorizationRequired
1415 | AcpThreadEvent::PromptCapabilitiesUpdated
1416 | AcpThreadEvent::AvailableCommandsUpdated(_)
1417 | AcpThreadEvent::Retry(_)
1418 | AcpThreadEvent::ModeUpdated(_) => {}
1419 }
1420 }
1421
1422 fn handle_workspace_event(
1423 &mut self,
1424 workspace: &Entity<Workspace>,
1425 event: &workspace::Event,
1426 window: &mut Window,
1427 cx: &mut Context<Self>,
1428 ) {
1429 if let workspace::Event::ItemAdded { item } = event
1430 && let Some(editor) = item.downcast::<Editor>()
1431 && let Some(buffer) = Self::full_editor_buffer(editor.read(cx), cx)
1432 {
1433 self.register_editor(workspace.downgrade(), buffer, editor, window, cx);
1434 }
1435 }
1436
1437 fn full_editor_buffer(editor: &Editor, cx: &App) -> Option<WeakEntity<Buffer>> {
1438 if editor.mode().is_full() {
1439 editor
1440 .buffer()
1441 .read(cx)
1442 .as_singleton()
1443 .map(|buffer| buffer.downgrade())
1444 } else {
1445 None
1446 }
1447 }
1448
1449 fn register_editor(
1450 &mut self,
1451 workspace: WeakEntity<Workspace>,
1452 buffer: WeakEntity<Buffer>,
1453 editor: Entity<Editor>,
1454 window: &mut Window,
1455 cx: &mut Context<Self>,
1456 ) {
1457 let Some(workspace_thread) = self.workspace_threads.get_mut(&workspace) else {
1458 return;
1459 };
1460
1461 let weak_editor = editor.downgrade();
1462
1463 workspace_thread
1464 .singleton_editors
1465 .entry(buffer.clone())
1466 .or_default()
1467 .entry(weak_editor.clone())
1468 .or_insert_with(|| {
1469 let workspace = workspace.clone();
1470 cx.observe_release(&editor, move |this, _, _cx| {
1471 let Some(active_thread) = this.workspace_threads.get_mut(&workspace) else {
1472 return;
1473 };
1474
1475 if let Entry::Occupied(mut entry) =
1476 active_thread.singleton_editors.entry(buffer)
1477 {
1478 let set = entry.get_mut();
1479 set.remove(&weak_editor);
1480
1481 if set.is_empty() {
1482 entry.remove();
1483 }
1484 }
1485 })
1486 });
1487
1488 self.update_reviewing_editors(&workspace, window, cx);
1489 }
1490
1491 fn update_reviewing_editors(
1492 &mut self,
1493 workspace: &WeakEntity<Workspace>,
1494 window: &mut Window,
1495 cx: &mut Context<Self>,
1496 ) {
1497 if !AgentSettings::get_global(cx).single_file_review {
1498 for (editor, _) in self.reviewing_editors.drain() {
1499 editor
1500 .update(cx, |editor, cx| {
1501 editor.end_temporary_diff_override(cx);
1502 editor.unregister_addon::<EditorAgentDiffAddon>();
1503 })
1504 .ok();
1505 }
1506 return;
1507 }
1508
1509 let Some(workspace_thread) = self.workspace_threads.get_mut(workspace) else {
1510 return;
1511 };
1512
1513 let Some(thread) = workspace_thread.thread.upgrade() else {
1514 return;
1515 };
1516
1517 let action_log = thread.action_log(cx);
1518 let changed_buffers = action_log.read(cx).changed_buffers(cx);
1519
1520 let mut unaffected = self.reviewing_editors.clone();
1521
1522 for (buffer, diff_handle) in changed_buffers {
1523 if buffer.read(cx).file().is_none() {
1524 continue;
1525 }
1526
1527 let Some(buffer_editors) = workspace_thread.singleton_editors.get(&buffer.downgrade())
1528 else {
1529 continue;
1530 };
1531
1532 for weak_editor in buffer_editors.keys() {
1533 let Some(editor) = weak_editor.upgrade() else {
1534 continue;
1535 };
1536
1537 let multibuffer = editor.read(cx).buffer().clone();
1538 multibuffer.update(cx, |multibuffer, cx| {
1539 multibuffer.add_diff(diff_handle.clone(), cx);
1540 });
1541
1542 let new_state = if thread.is_generating(cx) {
1543 EditorState::Generating
1544 } else {
1545 EditorState::Reviewing
1546 };
1547
1548 let previous_state = self
1549 .reviewing_editors
1550 .insert(weak_editor.clone(), new_state.clone());
1551
1552 if previous_state.is_none() {
1553 editor.update(cx, |editor, cx| {
1554 editor.start_temporary_diff_override();
1555 editor.set_render_diff_hunk_controls(diff_hunk_controls(&thread), cx);
1556 editor.set_expand_all_diff_hunks(cx);
1557 editor.register_addon(EditorAgentDiffAddon);
1558 });
1559 } else {
1560 unaffected.remove(weak_editor);
1561 }
1562
1563 if new_state == EditorState::Reviewing && previous_state != Some(new_state) {
1564 // Jump to first hunk when we enter review mode
1565 editor.update(cx, |editor, cx| {
1566 let snapshot = multibuffer.read(cx).snapshot(cx);
1567 if let Some(first_hunk) = snapshot.diff_hunks().next() {
1568 let first_hunk_start = first_hunk.multi_buffer_range().start;
1569
1570 editor.change_selections(
1571 SelectionEffects::scroll(Autoscroll::center()),
1572 window,
1573 cx,
1574 |selections| {
1575 selections.select_ranges([first_hunk_start..first_hunk_start])
1576 },
1577 );
1578 }
1579 });
1580 }
1581 }
1582 }
1583
1584 // Remove editors from this workspace that are no longer under review
1585 for (editor, _) in unaffected {
1586 // Note: We could avoid this check by storing `reviewing_editors` by Workspace,
1587 // but that would add another lookup in `AgentDiff::editor_state`
1588 // which gets called much more frequently.
1589 let in_workspace = editor
1590 .read_with(cx, |editor, _cx| editor.workspace())
1591 .ok()
1592 .flatten()
1593 .is_some_and(|editor_workspace| {
1594 editor_workspace.entity_id() == workspace.entity_id()
1595 });
1596
1597 if in_workspace {
1598 editor
1599 .update(cx, |editor, cx| {
1600 editor.end_temporary_diff_override(cx);
1601 editor.unregister_addon::<EditorAgentDiffAddon>();
1602 })
1603 .ok();
1604 self.reviewing_editors.remove(&editor);
1605 }
1606 }
1607
1608 cx.notify();
1609 }
1610
1611 fn editor_state(&self, editor: &WeakEntity<Editor>) -> EditorState {
1612 self.reviewing_editors
1613 .get(editor)
1614 .cloned()
1615 .unwrap_or(EditorState::Idle)
1616 }
1617
1618 fn deploy_pane_from_editor(&self, editor: &Entity<Editor>, window: &mut Window, cx: &mut App) {
1619 let Some(workspace) = editor.read(cx).workspace() else {
1620 return;
1621 };
1622
1623 let Some(WorkspaceThread { thread, .. }) =
1624 self.workspace_threads.get(&workspace.downgrade())
1625 else {
1626 return;
1627 };
1628
1629 let Some(thread) = thread.upgrade() else {
1630 return;
1631 };
1632
1633 AgentDiffPane::deploy(thread, workspace.downgrade(), window, cx).log_err();
1634 }
1635
1636 fn keep_all(
1637 editor: &Entity<Editor>,
1638 thread: &AgentDiffThread,
1639 window: &mut Window,
1640 cx: &mut App,
1641 ) -> PostReviewState {
1642 editor.update(cx, |editor, cx| {
1643 let snapshot = editor.buffer().read(cx).snapshot(cx);
1644 keep_edits_in_ranges(
1645 editor,
1646 &snapshot,
1647 thread,
1648 vec![editor::Anchor::min()..editor::Anchor::max()],
1649 window,
1650 cx,
1651 );
1652 });
1653 PostReviewState::AllReviewed
1654 }
1655
1656 fn reject_all(
1657 editor: &Entity<Editor>,
1658 thread: &AgentDiffThread,
1659 window: &mut Window,
1660 cx: &mut App,
1661 ) -> PostReviewState {
1662 editor.update(cx, |editor, cx| {
1663 let snapshot = editor.buffer().read(cx).snapshot(cx);
1664 reject_edits_in_ranges(
1665 editor,
1666 &snapshot,
1667 thread,
1668 vec![editor::Anchor::min()..editor::Anchor::max()],
1669 window,
1670 cx,
1671 );
1672 });
1673 PostReviewState::AllReviewed
1674 }
1675
1676 fn keep(
1677 editor: &Entity<Editor>,
1678 thread: &AgentDiffThread,
1679 window: &mut Window,
1680 cx: &mut App,
1681 ) -> PostReviewState {
1682 editor.update(cx, |editor, cx| {
1683 let snapshot = editor.buffer().read(cx).snapshot(cx);
1684 keep_edits_in_selection(editor, &snapshot, thread, window, cx);
1685 Self::post_review_state(&snapshot)
1686 })
1687 }
1688
1689 fn reject(
1690 editor: &Entity<Editor>,
1691 thread: &AgentDiffThread,
1692 window: &mut Window,
1693 cx: &mut App,
1694 ) -> PostReviewState {
1695 editor.update(cx, |editor, cx| {
1696 let snapshot = editor.buffer().read(cx).snapshot(cx);
1697 reject_edits_in_selection(editor, &snapshot, thread, window, cx);
1698 Self::post_review_state(&snapshot)
1699 })
1700 }
1701
1702 fn post_review_state(snapshot: &MultiBufferSnapshot) -> PostReviewState {
1703 for (i, _) in snapshot.diff_hunks().enumerate() {
1704 if i > 0 {
1705 return PostReviewState::Pending;
1706 }
1707 }
1708 PostReviewState::AllReviewed
1709 }
1710
1711 fn review_in_active_editor(
1712 &mut self,
1713 workspace: &mut Workspace,
1714 review: impl Fn(&Entity<Editor>, &AgentDiffThread, &mut Window, &mut App) -> PostReviewState,
1715 window: &mut Window,
1716 cx: &mut Context<Self>,
1717 ) -> Option<Task<Result<()>>> {
1718 let active_item = workspace.active_item(cx)?;
1719 let editor = active_item.act_as::<Editor>(cx)?;
1720
1721 if !matches!(
1722 self.editor_state(&editor.downgrade()),
1723 EditorState::Reviewing
1724 ) {
1725 return None;
1726 }
1727
1728 let WorkspaceThread { thread, .. } =
1729 self.workspace_threads.get(&workspace.weak_handle())?;
1730
1731 let thread = thread.upgrade()?;
1732
1733 if let PostReviewState::AllReviewed = review(&editor, &thread, window, cx)
1734 && let Some(curr_buffer) = editor.read(cx).buffer().read(cx).as_singleton()
1735 {
1736 let changed_buffers = thread.action_log(cx).read(cx).changed_buffers(cx);
1737
1738 let mut keys = changed_buffers.keys().cycle();
1739 keys.find(|k| *k == &curr_buffer);
1740 let next_project_path = keys
1741 .next()
1742 .filter(|k| *k != &curr_buffer)
1743 .and_then(|after| after.read(cx).project_path(cx));
1744
1745 if let Some(path) = next_project_path {
1746 let task = workspace.open_path(path, None, true, window, cx);
1747 let task = cx.spawn(async move |_, _cx| task.await.map(|_| ()));
1748 return Some(task);
1749 }
1750 }
1751
1752 Some(Task::ready(Ok(())))
1753 }
1754}
1755
1756enum PostReviewState {
1757 AllReviewed,
1758 Pending,
1759}
1760
1761pub struct EditorAgentDiffAddon;
1762
1763impl editor::Addon for EditorAgentDiffAddon {
1764 fn to_any(&self) -> &dyn std::any::Any {
1765 self
1766 }
1767
1768 fn extend_key_context(&self, key_context: &mut gpui::KeyContext, _: &App) {
1769 key_context.add("agent_diff");
1770 key_context.add("editor_agent_diff");
1771 }
1772}
1773
1774#[cfg(test)]
1775mod tests {
1776 use super::*;
1777 use crate::Keep;
1778 use acp_thread::AgentConnection as _;
1779 use agent_settings::AgentSettings;
1780 use editor::EditorSettings;
1781 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
1782 use project::{FakeFs, Project};
1783 use serde_json::json;
1784 use settings::{Settings, SettingsStore};
1785 use std::{path::Path, rc::Rc};
1786 use util::path;
1787
1788 #[gpui::test]
1789 async fn test_multibuffer_agent_diff(cx: &mut TestAppContext) {
1790 cx.update(|cx| {
1791 let settings_store = SettingsStore::test(cx);
1792 cx.set_global(settings_store);
1793 language::init(cx);
1794 Project::init_settings(cx);
1795 AgentSettings::register(cx);
1796 prompt_store::init(cx);
1797 workspace::init_settings(cx);
1798 theme::init(theme::LoadThemes::JustBase, cx);
1799 EditorSettings::register(cx);
1800 language_model::init_settings(cx);
1801 });
1802
1803 let fs = FakeFs::new(cx.executor());
1804 fs.insert_tree(
1805 path!("/test"),
1806 json!({"file1": "abc\ndef\nghi\njkl\nmno\npqr\nstu\nvwx\nyz"}),
1807 )
1808 .await;
1809 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
1810 let buffer_path = project
1811 .read_with(cx, |project, cx| {
1812 project.find_project_path("test/file1", cx)
1813 })
1814 .unwrap();
1815
1816 let connection = Rc::new(acp_thread::StubAgentConnection::new());
1817 let thread = cx
1818 .update(|cx| {
1819 connection
1820 .clone()
1821 .new_thread(project.clone(), Path::new(path!("/test")), cx)
1822 })
1823 .await
1824 .unwrap();
1825
1826 let thread = AgentDiffThread::AcpThread(thread);
1827 let action_log = cx.read(|cx| thread.action_log(cx));
1828
1829 let (workspace, cx) =
1830 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1831 let agent_diff = cx.new_window_entity(|window, cx| {
1832 AgentDiffPane::new(thread.clone(), workspace.downgrade(), window, cx)
1833 });
1834 let editor = agent_diff.read_with(cx, |diff, _cx| diff.editor.clone());
1835
1836 let buffer = project
1837 .update(cx, |project, cx| project.open_buffer(buffer_path, cx))
1838 .await
1839 .unwrap();
1840 cx.update(|_, cx| {
1841 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1842 buffer.update(cx, |buffer, cx| {
1843 buffer
1844 .edit(
1845 [
1846 (Point::new(1, 1)..Point::new(1, 2), "E"),
1847 (Point::new(3, 2)..Point::new(3, 3), "L"),
1848 (Point::new(5, 0)..Point::new(5, 1), "P"),
1849 (Point::new(7, 1)..Point::new(7, 2), "W"),
1850 ],
1851 None,
1852 cx,
1853 )
1854 .unwrap()
1855 });
1856 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1857 });
1858 cx.run_until_parked();
1859
1860 // When opening the assistant diff, the cursor is positioned on the first hunk.
1861 assert_eq!(
1862 editor.read_with(cx, |editor, cx| editor.text(cx)),
1863 "abc\ndef\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nvWx\nyz"
1864 );
1865 assert_eq!(
1866 editor
1867 .update(cx, |editor, cx| editor
1868 .selections
1869 .newest::<Point>(&editor.display_snapshot(cx)))
1870 .range(),
1871 Point::new(1, 0)..Point::new(1, 0)
1872 );
1873
1874 // After keeping a hunk, the cursor should be positioned on the second hunk.
1875 agent_diff.update_in(cx, |diff, window, cx| diff.keep(&Keep, window, cx));
1876 cx.run_until_parked();
1877 assert_eq!(
1878 editor.read_with(cx, |editor, cx| editor.text(cx)),
1879 "abc\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nvWx\nyz"
1880 );
1881 assert_eq!(
1882 editor
1883 .update(cx, |editor, cx| editor
1884 .selections
1885 .newest::<Point>(&editor.display_snapshot(cx)))
1886 .range(),
1887 Point::new(3, 0)..Point::new(3, 0)
1888 );
1889
1890 // Rejecting a hunk also moves the cursor to the next hunk, possibly cycling if it's at the end.
1891 editor.update_in(cx, |editor, window, cx| {
1892 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
1893 selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)])
1894 });
1895 });
1896 agent_diff.update_in(cx, |diff, window, cx| {
1897 diff.reject(&crate::Reject, window, cx)
1898 });
1899 cx.run_until_parked();
1900 assert_eq!(
1901 editor.read_with(cx, |editor, cx| editor.text(cx)),
1902 "abc\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nyz"
1903 );
1904 assert_eq!(
1905 editor
1906 .update(cx, |editor, cx| editor
1907 .selections
1908 .newest::<Point>(&editor.display_snapshot(cx)))
1909 .range(),
1910 Point::new(3, 0)..Point::new(3, 0)
1911 );
1912
1913 // Keeping a range that doesn't intersect the current selection doesn't move it.
1914 agent_diff.update_in(cx, |_diff, window, cx| {
1915 let position = editor
1916 .read(cx)
1917 .buffer()
1918 .read(cx)
1919 .read(cx)
1920 .anchor_before(Point::new(7, 0));
1921 editor.update(cx, |editor, cx| {
1922 let snapshot = editor.buffer().read(cx).snapshot(cx);
1923 keep_edits_in_ranges(
1924 editor,
1925 &snapshot,
1926 &thread,
1927 vec![position..position],
1928 window,
1929 cx,
1930 )
1931 });
1932 });
1933 cx.run_until_parked();
1934 assert_eq!(
1935 editor.read_with(cx, |editor, cx| editor.text(cx)),
1936 "abc\ndEf\nghi\njkl\njkL\nmno\nPqr\nstu\nvwx\nyz"
1937 );
1938 assert_eq!(
1939 editor
1940 .update(cx, |editor, cx| editor
1941 .selections
1942 .newest::<Point>(&editor.display_snapshot(cx)))
1943 .range(),
1944 Point::new(3, 0)..Point::new(3, 0)
1945 );
1946 }
1947
1948 #[gpui::test]
1949 async fn test_singleton_agent_diff(cx: &mut TestAppContext) {
1950 cx.update(|cx| {
1951 let settings_store = SettingsStore::test(cx);
1952 cx.set_global(settings_store);
1953 language::init(cx);
1954 Project::init_settings(cx);
1955 AgentSettings::register(cx);
1956 prompt_store::init(cx);
1957 workspace::init_settings(cx);
1958 theme::init(theme::LoadThemes::JustBase, cx);
1959 EditorSettings::register(cx);
1960 language_model::init_settings(cx);
1961 workspace::register_project_item::<Editor>(cx);
1962 });
1963
1964 let fs = FakeFs::new(cx.executor());
1965 fs.insert_tree(
1966 path!("/test"),
1967 json!({"file1": "abc\ndef\nghi\njkl\nmno\npqr\nstu\nvwx\nyz"}),
1968 )
1969 .await;
1970 fs.insert_tree(path!("/test"), json!({"file2": "abc\ndef\nghi"}))
1971 .await;
1972
1973 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
1974 let buffer_path1 = project
1975 .read_with(cx, |project, cx| {
1976 project.find_project_path("test/file1", cx)
1977 })
1978 .unwrap();
1979 let buffer_path2 = project
1980 .read_with(cx, |project, cx| {
1981 project.find_project_path("test/file2", cx)
1982 })
1983 .unwrap();
1984
1985 let (workspace, cx) =
1986 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1987
1988 // Add the diff toolbar to the active pane
1989 let diff_toolbar = cx.new_window_entity(|_, cx| AgentDiffToolbar::new(cx));
1990
1991 workspace.update_in(cx, {
1992 let diff_toolbar = diff_toolbar.clone();
1993
1994 move |workspace, window, cx| {
1995 workspace.active_pane().update(cx, |pane, cx| {
1996 pane.toolbar().update(cx, |toolbar, cx| {
1997 toolbar.add_item(diff_toolbar, window, cx);
1998 });
1999 })
2000 }
2001 });
2002
2003 let connection = Rc::new(acp_thread::StubAgentConnection::new());
2004 let thread = cx
2005 .update(|_, cx| {
2006 connection
2007 .clone()
2008 .new_thread(project.clone(), Path::new(path!("/test")), cx)
2009 })
2010 .await
2011 .unwrap();
2012 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2013
2014 // Set the active thread
2015 let thread = AgentDiffThread::AcpThread(thread);
2016 cx.update(|window, cx| {
2017 AgentDiff::set_active_thread(&workspace.downgrade(), thread.clone(), window, cx)
2018 });
2019
2020 let buffer1 = project
2021 .update(cx, |project, cx| {
2022 project.open_buffer(buffer_path1.clone(), cx)
2023 })
2024 .await
2025 .unwrap();
2026 let buffer2 = project
2027 .update(cx, |project, cx| {
2028 project.open_buffer(buffer_path2.clone(), cx)
2029 })
2030 .await
2031 .unwrap();
2032
2033 // Open an editor for buffer1
2034 let editor1 = cx.new_window_entity(|window, cx| {
2035 Editor::for_buffer(buffer1.clone(), Some(project.clone()), window, cx)
2036 });
2037
2038 workspace.update_in(cx, |workspace, window, cx| {
2039 workspace.add_item_to_active_pane(Box::new(editor1.clone()), None, true, window, cx);
2040 });
2041 cx.run_until_parked();
2042
2043 // Toolbar knows about the current editor, but it's hidden since there are no changes yet
2044 assert!(diff_toolbar.read_with(cx, |toolbar, _cx| matches!(
2045 toolbar.active_item,
2046 Some(AgentDiffToolbarItem::Editor {
2047 state: EditorState::Idle,
2048 ..
2049 })
2050 )));
2051 assert_eq!(
2052 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2053 ToolbarItemLocation::Hidden
2054 );
2055
2056 // Make changes
2057 cx.update(|_, cx| {
2058 action_log.update(cx, |log, cx| log.buffer_read(buffer1.clone(), cx));
2059 buffer1.update(cx, |buffer, cx| {
2060 buffer
2061 .edit(
2062 [
2063 (Point::new(1, 1)..Point::new(1, 2), "E"),
2064 (Point::new(3, 2)..Point::new(3, 3), "L"),
2065 (Point::new(5, 0)..Point::new(5, 1), "P"),
2066 (Point::new(7, 1)..Point::new(7, 2), "W"),
2067 ],
2068 None,
2069 cx,
2070 )
2071 .unwrap()
2072 });
2073 action_log.update(cx, |log, cx| log.buffer_edited(buffer1.clone(), cx));
2074
2075 action_log.update(cx, |log, cx| log.buffer_read(buffer2.clone(), cx));
2076 buffer2.update(cx, |buffer, cx| {
2077 buffer
2078 .edit(
2079 [
2080 (Point::new(0, 0)..Point::new(0, 1), "A"),
2081 (Point::new(2, 1)..Point::new(2, 2), "H"),
2082 ],
2083 None,
2084 cx,
2085 )
2086 .unwrap();
2087 });
2088 action_log.update(cx, |log, cx| log.buffer_edited(buffer2.clone(), cx));
2089 });
2090 cx.run_until_parked();
2091
2092 // The already opened editor displays the diff and the cursor is at the first hunk
2093 assert_eq!(
2094 editor1.read_with(cx, |editor, cx| editor.text(cx)),
2095 "abc\ndef\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nvWx\nyz"
2096 );
2097 assert_eq!(
2098 editor1
2099 .update(cx, |editor, cx| editor
2100 .selections
2101 .newest::<Point>(&editor.display_snapshot(cx)))
2102 .range(),
2103 Point::new(1, 0)..Point::new(1, 0)
2104 );
2105
2106 // The toolbar is displayed in the right state
2107 assert_eq!(
2108 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2109 ToolbarItemLocation::PrimaryRight
2110 );
2111 assert!(diff_toolbar.read_with(cx, |toolbar, _cx| matches!(
2112 toolbar.active_item,
2113 Some(AgentDiffToolbarItem::Editor {
2114 state: EditorState::Reviewing,
2115 ..
2116 })
2117 )));
2118
2119 // The toolbar respects its setting
2120 override_toolbar_agent_review_setting(false, cx);
2121 assert_eq!(
2122 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2123 ToolbarItemLocation::Hidden
2124 );
2125 override_toolbar_agent_review_setting(true, cx);
2126 assert_eq!(
2127 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2128 ToolbarItemLocation::PrimaryRight
2129 );
2130
2131 // After keeping a hunk, the cursor should be positioned on the second hunk.
2132 workspace.update(cx, |_, cx| {
2133 cx.dispatch_action(&Keep);
2134 });
2135 cx.run_until_parked();
2136 assert_eq!(
2137 editor1.read_with(cx, |editor, cx| editor.text(cx)),
2138 "abc\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nvWx\nyz"
2139 );
2140 assert_eq!(
2141 editor1
2142 .update(cx, |editor, cx| editor
2143 .selections
2144 .newest::<Point>(&editor.display_snapshot(cx)))
2145 .range(),
2146 Point::new(3, 0)..Point::new(3, 0)
2147 );
2148
2149 // Rejecting a hunk also moves the cursor to the next hunk, possibly cycling if it's at the end.
2150 editor1.update_in(cx, |editor, window, cx| {
2151 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
2152 selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)])
2153 });
2154 });
2155 workspace.update(cx, |_, cx| {
2156 cx.dispatch_action(&Reject);
2157 });
2158 cx.run_until_parked();
2159 assert_eq!(
2160 editor1.read_with(cx, |editor, cx| editor.text(cx)),
2161 "abc\ndEf\nghi\njkl\njkL\nmno\npqr\nPqr\nstu\nvwx\nyz"
2162 );
2163 assert_eq!(
2164 editor1
2165 .update(cx, |editor, cx| editor
2166 .selections
2167 .newest::<Point>(&editor.display_snapshot(cx)))
2168 .range(),
2169 Point::new(3, 0)..Point::new(3, 0)
2170 );
2171
2172 // Keeping a range that doesn't intersect the current selection doesn't move it.
2173 editor1.update_in(cx, |editor, window, cx| {
2174 let buffer = editor.buffer().read(cx);
2175 let position = buffer.read(cx).anchor_before(Point::new(7, 0));
2176 let snapshot = buffer.snapshot(cx);
2177 keep_edits_in_ranges(
2178 editor,
2179 &snapshot,
2180 &thread,
2181 vec![position..position],
2182 window,
2183 cx,
2184 )
2185 });
2186 cx.run_until_parked();
2187 assert_eq!(
2188 editor1.read_with(cx, |editor, cx| editor.text(cx)),
2189 "abc\ndEf\nghi\njkl\njkL\nmno\nPqr\nstu\nvwx\nyz"
2190 );
2191 assert_eq!(
2192 editor1
2193 .update(cx, |editor, cx| editor
2194 .selections
2195 .newest::<Point>(&editor.display_snapshot(cx)))
2196 .range(),
2197 Point::new(3, 0)..Point::new(3, 0)
2198 );
2199
2200 // Reviewing the last change opens the next changed buffer
2201 workspace
2202 .update_in(cx, |workspace, window, cx| {
2203 AgentDiff::global(cx).update(cx, |agent_diff, cx| {
2204 agent_diff.review_in_active_editor(workspace, AgentDiff::keep, window, cx)
2205 })
2206 })
2207 .unwrap()
2208 .await
2209 .unwrap();
2210
2211 cx.run_until_parked();
2212
2213 let editor2 = workspace.update(cx, |workspace, cx| {
2214 workspace.active_item_as::<Editor>(cx).unwrap()
2215 });
2216
2217 let editor2_path = editor2
2218 .read_with(cx, |editor, cx| editor.project_path(cx))
2219 .unwrap();
2220 assert_eq!(editor2_path, buffer_path2);
2221
2222 assert_eq!(
2223 editor2.read_with(cx, |editor, cx| editor.text(cx)),
2224 "abc\nAbc\ndef\nghi\ngHi"
2225 );
2226 assert_eq!(
2227 editor2
2228 .update(cx, |editor, cx| editor
2229 .selections
2230 .newest::<Point>(&editor.display_snapshot(cx)))
2231 .range(),
2232 Point::new(0, 0)..Point::new(0, 0)
2233 );
2234
2235 // Editor 1 toolbar is hidden since all changes have been reviewed
2236 workspace.update_in(cx, |workspace, window, cx| {
2237 workspace.activate_item(&editor1, true, true, window, cx)
2238 });
2239
2240 assert!(diff_toolbar.read_with(cx, |toolbar, _cx| matches!(
2241 toolbar.active_item,
2242 Some(AgentDiffToolbarItem::Editor {
2243 state: EditorState::Idle,
2244 ..
2245 })
2246 )));
2247 assert_eq!(
2248 diff_toolbar.read_with(cx, |toolbar, cx| toolbar.location(cx)),
2249 ToolbarItemLocation::Hidden
2250 );
2251 }
2252
2253 fn override_toolbar_agent_review_setting(active: bool, cx: &mut VisualTestContext) {
2254 cx.update(|_window, cx| {
2255 SettingsStore::update_global(cx, |store, _cx| {
2256 let mut editor_settings = store.get::<EditorSettings>(None).clone();
2257 editor_settings.toolbar.agent_review = active;
2258 store.override_global(editor_settings);
2259 })
2260 });
2261 cx.run_until_parked();
2262 }
2263}