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