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