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