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