1use crate::{Thread, ThreadEvent};
2use anyhow::Result;
3use buffer_diff::DiffHunkStatus;
4use collections::HashSet;
5use editor::{Editor, EditorEvent, MultiBuffer};
6use futures::future;
7use gpui::{
8 prelude::*, AnyElement, AnyView, App, Entity, EventEmitter, FocusHandle, Focusable,
9 SharedString, Subscription, Task, WeakEntity, Window,
10};
11use language::{Capability, OffsetRangeExt};
12use multi_buffer::PathKey;
13use project::{Project, ProjectPath};
14use std::{
15 any::{Any, TypeId},
16 ops::Range,
17 sync::Arc,
18};
19use ui::{prelude::*, IconButtonShape};
20use util::TryFutureExt;
21use workspace::{
22 item::{BreadcrumbText, ItemEvent, TabContentParams},
23 searchable::SearchableItemHandle,
24 Item, ItemHandle, ItemNavHistory, ToolbarItemLocation, Workspace,
25};
26
27pub struct AssistantDiff {
28 multibuffer: Entity<MultiBuffer>,
29 editor: Entity<Editor>,
30 thread: Entity<Thread>,
31 focus_handle: FocusHandle,
32 workspace: WeakEntity<Workspace>,
33 title: SharedString,
34 _subscriptions: Vec<Subscription>,
35}
36
37impl AssistantDiff {
38 pub fn deploy(
39 thread: Entity<Thread>,
40 workspace: WeakEntity<Workspace>,
41 window: &mut Window,
42 cx: &mut App,
43 ) -> Result<()> {
44 let existing_diff = workspace.update(cx, |workspace, cx| {
45 workspace
46 .items_of_type::<AssistantDiff>(cx)
47 .find(|diff| diff.read(cx).thread == thread)
48 })?;
49 if let Some(existing_diff) = existing_diff {
50 workspace.update(cx, |workspace, cx| {
51 workspace.activate_item(&existing_diff, true, true, window, cx);
52 })
53 } else {
54 let assistant_diff =
55 cx.new(|cx| AssistantDiff::new(thread.clone(), workspace.clone(), window, cx));
56 workspace.update(cx, |workspace, cx| {
57 workspace.add_item_to_center(Box::new(assistant_diff), window, cx);
58 })
59 }
60 }
61
62 pub fn new(
63 thread: Entity<Thread>,
64 workspace: WeakEntity<Workspace>,
65 window: &mut Window,
66 cx: &mut Context<Self>,
67 ) -> Self {
68 let focus_handle = cx.focus_handle();
69 let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
70
71 let project = thread.read(cx).project().clone();
72 let render_diff_hunk_controls = Arc::new({
73 let assistant_diff = cx.entity();
74 move |row,
75 status: &DiffHunkStatus,
76 hunk_range,
77 is_created_file,
78 line_height,
79 _editor: &Entity<Editor>,
80 cx: &mut App| {
81 render_diff_hunk_controls(
82 row,
83 status,
84 hunk_range,
85 is_created_file,
86 line_height,
87 &assistant_diff,
88 cx,
89 )
90 }
91 });
92 let editor = cx.new(|cx| {
93 let mut editor =
94 Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
95 editor.disable_inline_diagnostics();
96 editor.set_expand_all_diff_hunks(cx);
97 editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx);
98 editor
99 });
100
101 let action_log = thread.read(cx).action_log().clone();
102 let mut this = Self {
103 _subscriptions: vec![
104 cx.observe_in(&action_log, window, |this, _action_log, window, cx| {
105 this.update_excerpts(window, cx)
106 }),
107 cx.subscribe(&thread, |this, _thread, event, cx| {
108 this.handle_thread_event(event, cx)
109 }),
110 ],
111 title: SharedString::default(),
112 multibuffer,
113 editor,
114 thread,
115 focus_handle,
116 workspace,
117 };
118 this.update_excerpts(window, cx);
119 this.update_title(cx);
120 this
121 }
122
123 fn update_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
124 let thread = self.thread.read(cx);
125 let unreviewed_buffers = thread.action_log().read(cx).unreviewed_buffers();
126 let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
127
128 for (buffer, tracked) in unreviewed_buffers {
129 let Some(file) = buffer.read(cx).file().cloned() else {
130 continue;
131 };
132
133 let path_key = PathKey::namespaced("", file.full_path(cx).into());
134 paths_to_delete.remove(&path_key);
135
136 let snapshot = buffer.read(cx).snapshot();
137 let diff = tracked.diff().read(cx);
138 let diff_hunk_ranges = diff
139 .hunks_intersecting_range(
140 language::Anchor::MIN..language::Anchor::MAX,
141 &snapshot,
142 cx,
143 )
144 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
145 .collect::<Vec<_>>();
146
147 let was_empty = self.multibuffer.update(cx, |multibuffer, cx| {
148 let was_empty = multibuffer.is_empty();
149 multibuffer.set_excerpts_for_path(
150 path_key.clone(),
151 buffer,
152 diff_hunk_ranges,
153 editor::DEFAULT_MULTIBUFFER_CONTEXT,
154 cx,
155 );
156 multibuffer.add_diff(tracked.diff().clone(), cx);
157 was_empty
158 });
159
160 self.editor.update(cx, |editor, cx| {
161 if was_empty {
162 editor.change_selections(None, window, cx, |selections| {
163 // TODO select the very beginning (possibly inside a deletion)
164 selections.select_ranges([0..0])
165 });
166 }
167 });
168 }
169
170 self.multibuffer.update(cx, |multibuffer, cx| {
171 for path in paths_to_delete {
172 multibuffer.remove_excerpts_for_path(path, cx);
173 }
174 });
175
176 if self.multibuffer.read(cx).is_empty()
177 && self
178 .editor
179 .read(cx)
180 .focus_handle(cx)
181 .contains_focused(window, cx)
182 {
183 self.focus_handle.focus(window);
184 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
185 self.editor.update(cx, |editor, cx| {
186 editor.focus_handle(cx).focus(window);
187 });
188 }
189 }
190
191 fn update_title(&mut self, cx: &mut Context<Self>) {
192 let new_title = self
193 .thread
194 .read(cx)
195 .summary()
196 .unwrap_or("Assistant Changes".into());
197 if new_title != self.title {
198 self.title = new_title;
199 cx.emit(EditorEvent::TitleChanged);
200 }
201 }
202
203 fn handle_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context<Self>) {
204 match event {
205 ThreadEvent::SummaryChanged => self.update_title(cx),
206 _ => {}
207 }
208 }
209
210 fn review_diff_hunks(
211 &mut self,
212 hunk_ranges: Vec<Range<editor::Anchor>>,
213 accept: bool,
214 window: &mut Window,
215 cx: &mut Context<Self>,
216 ) {
217 let snapshot = self.multibuffer.read(cx).snapshot(cx);
218 let diff_hunks_in_ranges = self
219 .editor
220 .read(cx)
221 .diff_hunks_in_ranges(&hunk_ranges, &snapshot)
222 .collect::<Vec<_>>();
223
224 let mut tasks = Vec::new();
225 for hunk in diff_hunks_in_ranges {
226 let buffer = self.multibuffer.read(cx).buffer(hunk.buffer_id);
227 if let Some(buffer) = buffer {
228 let task = self.thread.update(cx, |thread, cx| {
229 thread.review_edits_in_range(buffer, hunk.buffer_range, accept, cx)
230 });
231 tasks.push(task.log_err());
232 }
233 }
234
235 cx.spawn_in(window, async move |this, cx| {
236 future::join_all(tasks).await;
237 this.update_in(cx, |this, window, cx| this.update_excerpts(window, cx))
238 })
239 .detach_and_log_err(cx);
240 }
241}
242
243impl EventEmitter<EditorEvent> for AssistantDiff {}
244
245impl Focusable for AssistantDiff {
246 fn focus_handle(&self, cx: &App) -> FocusHandle {
247 if self.multibuffer.read(cx).is_empty() {
248 self.focus_handle.clone()
249 } else {
250 self.editor.focus_handle(cx)
251 }
252 }
253}
254
255impl Item for AssistantDiff {
256 type Event = EditorEvent;
257
258 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
259 Some(Icon::new(IconName::ZedAssistant).color(Color::Muted))
260 }
261
262 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
263 Editor::to_item_events(event, f)
264 }
265
266 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
267 self.editor
268 .update(cx, |editor, cx| editor.deactivated(window, cx));
269 }
270
271 fn navigate(
272 &mut self,
273 data: Box<dyn Any>,
274 window: &mut Window,
275 cx: &mut Context<Self>,
276 ) -> bool {
277 self.editor
278 .update(cx, |editor, cx| editor.navigate(data, window, cx))
279 }
280
281 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
282 Some("Project Diff".into())
283 }
284
285 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
286 let summary = self
287 .thread
288 .read(cx)
289 .summary()
290 .unwrap_or("Assistant Changes".into());
291 Label::new(format!("Review: {}", summary))
292 .color(if params.selected {
293 Color::Default
294 } else {
295 Color::Muted
296 })
297 .into_any_element()
298 }
299
300 fn telemetry_event_text(&self) -> Option<&'static str> {
301 Some("Project Diff Opened")
302 }
303
304 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
305 Some(Box::new(self.editor.clone()))
306 }
307
308 fn for_each_project_item(
309 &self,
310 cx: &App,
311 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
312 ) {
313 self.editor.for_each_project_item(cx, f)
314 }
315
316 fn is_singleton(&self, _: &App) -> bool {
317 false
318 }
319
320 fn set_nav_history(
321 &mut self,
322 nav_history: ItemNavHistory,
323 _: &mut Window,
324 cx: &mut Context<Self>,
325 ) {
326 self.editor.update(cx, |editor, _| {
327 editor.set_nav_history(Some(nav_history));
328 });
329 }
330
331 fn clone_on_split(
332 &self,
333 _workspace_id: Option<workspace::WorkspaceId>,
334 window: &mut Window,
335 cx: &mut Context<Self>,
336 ) -> Option<Entity<Self>>
337 where
338 Self: Sized,
339 {
340 Some(cx.new(|cx| Self::new(self.thread.clone(), self.workspace.clone(), window, cx)))
341 }
342
343 fn is_dirty(&self, cx: &App) -> bool {
344 self.multibuffer.read(cx).is_dirty(cx)
345 }
346
347 fn has_conflict(&self, cx: &App) -> bool {
348 self.multibuffer.read(cx).has_conflict(cx)
349 }
350
351 fn can_save(&self, _: &App) -> bool {
352 true
353 }
354
355 fn save(
356 &mut self,
357 format: bool,
358 project: Entity<Project>,
359 window: &mut Window,
360 cx: &mut Context<Self>,
361 ) -> Task<Result<()>> {
362 self.editor.save(format, project, window, cx)
363 }
364
365 fn save_as(
366 &mut self,
367 _: Entity<Project>,
368 _: ProjectPath,
369 _window: &mut Window,
370 _: &mut Context<Self>,
371 ) -> Task<Result<()>> {
372 unreachable!()
373 }
374
375 fn reload(
376 &mut self,
377 project: Entity<Project>,
378 window: &mut Window,
379 cx: &mut Context<Self>,
380 ) -> Task<Result<()>> {
381 self.editor.reload(project, window, cx)
382 }
383
384 fn act_as_type<'a>(
385 &'a self,
386 type_id: TypeId,
387 self_handle: &'a Entity<Self>,
388 _: &'a App,
389 ) -> Option<AnyView> {
390 if type_id == TypeId::of::<Self>() {
391 Some(self_handle.to_any())
392 } else if type_id == TypeId::of::<Editor>() {
393 Some(self.editor.to_any())
394 } else {
395 None
396 }
397 }
398
399 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
400 ToolbarItemLocation::PrimaryLeft
401 }
402
403 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
404 self.editor.breadcrumbs(theme, cx)
405 }
406
407 fn added_to_workspace(
408 &mut self,
409 workspace: &mut Workspace,
410 window: &mut Window,
411 cx: &mut Context<Self>,
412 ) {
413 self.editor.update(cx, |editor, cx| {
414 editor.added_to_workspace(workspace, window, cx)
415 });
416 }
417}
418
419impl Render for AssistantDiff {
420 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
421 let is_empty = self.multibuffer.read(cx).is_empty();
422 div()
423 .track_focus(&self.focus_handle)
424 .key_context(if is_empty {
425 "EmptyPane"
426 } else {
427 "AssistantDiff"
428 })
429 .bg(cx.theme().colors().editor_background)
430 .flex()
431 .items_center()
432 .justify_center()
433 .size_full()
434 .when(is_empty, |el| el.child("No changes to review"))
435 .when(!is_empty, |el| el.child(self.editor.clone()))
436 }
437}
438
439fn render_diff_hunk_controls(
440 row: u32,
441 status: &DiffHunkStatus,
442 hunk_range: Range<editor::Anchor>,
443 is_created_file: bool,
444 line_height: Pixels,
445 assistant_diff: &Entity<AssistantDiff>,
446 cx: &mut App,
447) -> AnyElement {
448 let editor = assistant_diff.read(cx).editor.clone();
449 h_flex()
450 .h(line_height)
451 .mr_1()
452 .gap_1()
453 .px_0p5()
454 .pb_1()
455 .border_x_1()
456 .border_b_1()
457 .border_color(cx.theme().colors().border_variant)
458 .rounded_b_lg()
459 .bg(cx.theme().colors().editor_background)
460 .gap_1()
461 .occlude()
462 .shadow_md()
463 .children(if status.has_secondary_hunk() {
464 vec![
465 Button::new(("stage", row as u64), "Accept")
466 .alpha(if status.is_pending() { 0.66 } else { 1.0 })
467 // TODO: add tooltip
468 // .tooltip({
469 // let focus_handle = editor.focus_handle(cx);
470 // move |window, cx| {
471 // Tooltip::for_action_in(
472 // "Stage Hunk",
473 // &::git::ToggleStaged,
474 // &focus_handle,
475 // window,
476 // cx,
477 // )
478 // }
479 // })
480 .on_click({
481 let assistant_diff = assistant_diff.clone();
482 move |_event, window, cx| {
483 assistant_diff.update(cx, |diff, cx| {
484 diff.review_diff_hunks(
485 vec![hunk_range.start..hunk_range.start],
486 true,
487 window,
488 cx,
489 );
490 });
491 }
492 }),
493 Button::new("undo", "Undo")
494 // TODO: add tooltip
495 // .tooltip({
496 // let focus_handle = editor.focus_handle(cx);
497 // move |window, cx| {
498 // Tooltip::for_action_in("Undo Hunk", &::git::Undo, &focus_handle, window, cx)
499 // }
500 // })
501 .on_click({
502 let _editor = editor.clone();
503 move |_event, _window, _cx| {
504 // editor.update(cx, |editor, cx| {
505 // let snapshot = editor.snapshot(window, cx);
506 // let point = hunk_range.start.to_point(&snapshot.buffer_snapshot);
507 // editor.undo_hunks_in_ranges(vec![point..point], window, cx);
508 // });
509 }
510 })
511 .disabled(is_created_file),
512 ]
513 } else {
514 vec![Button::new(("review", row as u64), "Review")
515 .alpha(if status.is_pending() { 0.66 } else { 1.0 })
516 // TODO: add tooltip
517 // .tooltip({
518 // let focus_handle = editor.focus_handle(cx);
519 // move |window, cx| {
520 // Tooltip::for_action_in(
521 // "Review",
522 // &::git::ToggleStaged,
523 // &focus_handle,
524 // window,
525 // cx,
526 // )
527 // }
528 // })
529 .on_click({
530 let assistant_diff = assistant_diff.clone();
531 move |_event, window, cx| {
532 assistant_diff.update(cx, |diff, cx| {
533 diff.review_diff_hunks(
534 vec![hunk_range.start..hunk_range.start],
535 false,
536 window,
537 cx,
538 );
539 });
540 }
541 })]
542 })
543 .when(
544 !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
545 |el| {
546 el.child(
547 IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
548 .shape(IconButtonShape::Square)
549 .icon_size(IconSize::Small)
550 // .disabled(!has_multiple_hunks)
551 // TODO: add tooltip
552 // .tooltip({
553 // let focus_handle = editor.focus_handle(cx);
554 // move |window, cx| {
555 // Tooltip::for_action_in(
556 // "Next Hunk",
557 // &GoToHunk,
558 // &focus_handle,
559 // window,
560 // cx,
561 // )
562 // }
563 // })
564 .on_click({
565 let _editor = editor.clone();
566 move |_event, _window, _cx| {
567 // TODO: wire this up
568 // editor.update(cx, |editor, cx| {
569 // let snapshot = editor.snapshot(window, cx);
570 // let position =
571 // hunk_range.end.to_point(&snapshot.buffer_snapshot);
572 // editor.go_to_hunk_before_or_after_position(
573 // &snapshot,
574 // position,
575 // Direction::Next,
576 // window,
577 // cx,
578 // );
579 // editor.expand_selected_diff_hunks(cx);
580 // });
581 }
582 }),
583 )
584 .child(
585 IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
586 .shape(IconButtonShape::Square)
587 .icon_size(IconSize::Small)
588 // .disabled(!has_multiple_hunks)
589 // TODO: add tooltip
590 // .tooltip({
591 // let focus_handle = editor.focus_handle(cx);
592 // move |window, cx| {
593 // Tooltip::for_action_in(
594 // "Previous Hunk",
595 // &GoToPreviousHunk,
596 // &focus_handle,
597 // window,
598 // cx,
599 // )
600 // }
601 // })
602 .on_click({
603 let _editor = editor.clone();
604 move |_event, _window, _cx| {
605 // TODO: wire this up
606 // editor.update(cx, |editor, cx| {
607 // let snapshot = editor.snapshot(window, cx);
608 // let point =
609 // hunk_range.start.to_point(&snapshot.buffer_snapshot);
610 // editor.go_to_hunk_before_or_after_position(
611 // &snapshot,
612 // point,
613 // Direction::Prev,
614 // window,
615 // cx,
616 // );
617 // editor.expand_selected_diff_hunks(cx);
618 // });
619 }
620 }),
621 )
622 },
623 )
624 .into_any_element()
625}