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