1use crate::{
2 conflict_view::ConflictAddon,
3 git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
4 git_panel_settings::GitPanelSettings,
5 remote_button::{render_publish_button, render_push_button},
6 resolve_active_repository,
7};
8use agent_settings::AgentSettings;
9use anyhow::{Context as _, Result, anyhow};
10use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
11use collections::{HashMap, HashSet};
12use editor::{
13 Addon, Editor, EditorEvent, EditorSettings, SelectionEffects, SplittableEditor,
14 actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent},
15 multibuffer_context_lines,
16 scroll::Autoscroll,
17};
18use git::repository::DiffType;
19
20use git::{
21 Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
22 repository::{Branch, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus},
23 status::FileStatus,
24};
25use gpui::{
26 Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
27 FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions,
28};
29use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt};
30use multi_buffer::{MultiBuffer, PathKey};
31use project::{
32 Project, ProjectPath,
33 git_store::{
34 Repository,
35 branch_diff::{self, BranchDiffEvent, DiffBase},
36 },
37};
38use settings::{Settings, SettingsStore};
39use smol::future::yield_now;
40use std::any::{Any, TypeId};
41use std::sync::Arc;
42use theme::ActiveTheme;
43use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
44use util::{ResultExt as _, rel_path::RelPath};
45use workspace::{
46 CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
47 ToolbarItemView, Workspace,
48 item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
49 notifications::NotifyTaskExt,
50 searchable::SearchableItemHandle,
51};
52use zed_actions::agent::ReviewBranchDiff;
53use ztracing::instrument;
54
55actions!(
56 git,
57 [
58 /// Shows the diff between the working directory and the index.
59 Diff,
60 /// Adds files to the git staging area.
61 Add,
62 /// Shows the diff between the working directory and your default
63 /// branch (typically main or master).
64 BranchDiff,
65 /// Opens a new agent thread with the branch diff for review.
66 ReviewDiff,
67 LeaderAndFollower,
68 ]
69);
70
71pub struct ProjectDiff {
72 project: Entity<Project>,
73 multibuffer: Entity<MultiBuffer>,
74 branch_diff: Entity<branch_diff::BranchDiff>,
75 editor: Entity<SplittableEditor>,
76 buffer_diff_subscriptions: HashMap<Arc<RelPath>, (Entity<BufferDiff>, Subscription)>,
77 workspace: WeakEntity<Workspace>,
78 focus_handle: FocusHandle,
79 pending_scroll: Option<PathKey>,
80 review_comment_count: usize,
81 _task: Task<Result<()>>,
82 _subscription: Subscription,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum RefreshReason {
87 DiffChanged,
88 StatusesChanged,
89 EditorSaved,
90}
91
92const CONFLICT_SORT_PREFIX: u64 = 1;
93const TRACKED_SORT_PREFIX: u64 = 2;
94const NEW_SORT_PREFIX: u64 = 3;
95
96impl ProjectDiff {
97 pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
98 workspace.register_action(Self::deploy);
99 workspace.register_action(Self::deploy_branch_diff);
100 workspace.register_action(Self::deploy_review_diff);
101 workspace.register_action(|workspace, _: &Add, window, cx| {
102 Self::deploy(workspace, &Diff, window, cx);
103 });
104 workspace::register_serializable_item::<ProjectDiff>(cx);
105 }
106
107 fn deploy(
108 workspace: &mut Workspace,
109 _: &Diff,
110 window: &mut Window,
111 cx: &mut Context<Workspace>,
112 ) {
113 Self::deploy_at(workspace, None, window, cx)
114 }
115
116 fn deploy_branch_diff(
117 workspace: &mut Workspace,
118 _: &BranchDiff,
119 window: &mut Window,
120 cx: &mut Context<Workspace>,
121 ) {
122 telemetry::event!("Git Branch Diff Opened");
123 let project = workspace.project().clone();
124
125 let existing = workspace
126 .items_of_type::<Self>(cx)
127 .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }));
128 if let Some(existing) = existing {
129 workspace.activate_item(&existing, true, true, window, cx);
130 return;
131 }
132 let workspace = cx.entity();
133 let workspace_weak = workspace.downgrade();
134 window
135 .spawn(cx, async move |cx| {
136 let this = cx
137 .update(|window, cx| {
138 Self::new_with_default_branch(project, workspace.clone(), window, cx)
139 })?
140 .await?;
141 workspace
142 .update_in(cx, |workspace, window, cx| {
143 workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
144 })
145 .ok();
146 anyhow::Ok(())
147 })
148 .detach_and_notify_err(workspace_weak, window, cx);
149 }
150
151 fn deploy_review_diff(
152 workspace: &mut Workspace,
153 _: &ReviewDiff,
154 window: &mut Window,
155 cx: &mut Context<Workspace>,
156 ) {
157 let Some(project_diff) = workspace
158 .items_of_type::<Self>(cx)
159 .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }))
160 else {
161 return;
162 };
163
164 let diff_base = project_diff.read(cx).diff_base(cx).clone();
165 let DiffBase::Merge { base_ref } = diff_base else {
166 return;
167 };
168
169 let Some(repo) = project_diff.read(cx).branch_diff.read(cx).repo().cloned() else {
170 return;
171 };
172
173 let diff_receiver = repo.update(cx, |repo, cx| {
174 repo.diff(
175 DiffType::MergeBase {
176 base_ref: base_ref.clone(),
177 },
178 cx,
179 )
180 });
181
182 let workspace_handle = cx.entity();
183 let workspace_weak = workspace_handle.downgrade();
184 window
185 .spawn(cx, async move |cx| {
186 let diff_text = diff_receiver.await??;
187
188 workspace_handle.update_in(cx, |_workspace, window, cx| {
189 window.dispatch_action(
190 ReviewBranchDiff {
191 diff_text: diff_text.into(),
192 base_ref: base_ref.to_string().into(),
193 }
194 .boxed_clone(),
195 cx,
196 );
197 })?;
198
199 anyhow::Ok(())
200 })
201 .detach_and_notify_err(workspace_weak, window, cx);
202 }
203
204 pub fn deploy_at(
205 workspace: &mut Workspace,
206 entry: Option<GitStatusEntry>,
207 window: &mut Window,
208 cx: &mut Context<Workspace>,
209 ) {
210 telemetry::event!(
211 "Git Diff Opened",
212 source = if entry.is_some() {
213 "Git Panel"
214 } else {
215 "Action"
216 }
217 );
218 let intended_repo = resolve_active_repository(workspace, cx);
219
220 let existing = workspace
221 .items_of_type::<Self>(cx)
222 .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
223 let project_diff = if let Some(existing) = existing {
224 existing.update(cx, |project_diff, cx| {
225 project_diff.move_to_beginning(window, cx);
226 });
227
228 workspace.activate_item(&existing, true, true, window, cx);
229 existing
230 } else {
231 let workspace_handle = cx.entity();
232 let project_diff =
233 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
234 workspace.add_item_to_active_pane(
235 Box::new(project_diff.clone()),
236 None,
237 true,
238 window,
239 cx,
240 );
241 project_diff
242 };
243
244 if let Some(intended) = &intended_repo {
245 let needs_switch = project_diff
246 .read(cx)
247 .branch_diff
248 .read(cx)
249 .repo()
250 .map_or(true, |current| current.read(cx).id != intended.read(cx).id);
251 if needs_switch {
252 project_diff.update(cx, |project_diff, cx| {
253 project_diff.branch_diff.update(cx, |branch_diff, cx| {
254 branch_diff.set_repo(Some(intended.clone()), cx);
255 });
256 });
257 }
258 }
259
260 if let Some(entry) = entry {
261 project_diff.update(cx, |project_diff, cx| {
262 project_diff.move_to_entry(entry, window, cx);
263 })
264 }
265 }
266
267 pub fn deploy_at_project_path(
268 workspace: &mut Workspace,
269 project_path: ProjectPath,
270 window: &mut Window,
271 cx: &mut Context<Workspace>,
272 ) {
273 telemetry::event!("Git Diff Opened", source = "Agent Panel");
274 let existing = workspace
275 .items_of_type::<Self>(cx)
276 .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
277 let project_diff = if let Some(existing) = existing {
278 workspace.activate_item(&existing, true, true, window, cx);
279 existing
280 } else {
281 let workspace_handle = cx.entity();
282 let project_diff =
283 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
284 workspace.add_item_to_active_pane(
285 Box::new(project_diff.clone()),
286 None,
287 true,
288 window,
289 cx,
290 );
291 project_diff
292 };
293 project_diff.update(cx, |project_diff, cx| {
294 project_diff.move_to_project_path(&project_path, window, cx);
295 });
296 }
297
298 pub fn autoscroll(&self, cx: &mut Context<Self>) {
299 self.editor.update(cx, |editor, cx| {
300 editor.rhs_editor().update(cx, |editor, cx| {
301 editor.request_autoscroll(Autoscroll::fit(), cx);
302 })
303 })
304 }
305
306 fn new_with_default_branch(
307 project: Entity<Project>,
308 workspace: Entity<Workspace>,
309 window: &mut Window,
310 cx: &mut App,
311 ) -> Task<Result<Entity<Self>>> {
312 let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
313 return Task::ready(Err(anyhow!("No active repository")));
314 };
315 let main_branch = repo.update(cx, |repo, _| repo.default_branch(true));
316 window.spawn(cx, async move |cx| {
317 let main_branch = main_branch
318 .await??
319 .context("Could not determine default branch")?;
320
321 let branch_diff = cx.new_window_entity(|window, cx| {
322 branch_diff::BranchDiff::new(
323 DiffBase::Merge {
324 base_ref: main_branch,
325 },
326 project.clone(),
327 window,
328 cx,
329 )
330 })?;
331 cx.new_window_entity(|window, cx| {
332 Self::new_impl(branch_diff, project, workspace, window, cx)
333 })
334 })
335 }
336
337 fn new(
338 project: Entity<Project>,
339 workspace: Entity<Workspace>,
340 window: &mut Window,
341 cx: &mut Context<Self>,
342 ) -> Self {
343 let branch_diff =
344 cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx));
345 Self::new_impl(branch_diff, project, workspace, window, cx)
346 }
347
348 fn new_impl(
349 branch_diff: Entity<branch_diff::BranchDiff>,
350 project: Entity<Project>,
351 workspace: Entity<Workspace>,
352 window: &mut Window,
353 cx: &mut Context<Self>,
354 ) -> Self {
355 let focus_handle = cx.focus_handle();
356 let multibuffer = cx.new(|cx| {
357 let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
358 multibuffer.set_all_diff_hunks_expanded(cx);
359 multibuffer
360 });
361
362 let editor = cx.new(|cx| {
363 let diff_display_editor = SplittableEditor::new(
364 EditorSettings::get_global(cx).diff_view_style,
365 multibuffer.clone(),
366 project.clone(),
367 workspace.clone(),
368 window,
369 cx,
370 );
371 match branch_diff.read(cx).diff_base() {
372 DiffBase::Head => {}
373 DiffBase::Merge { .. } => diff_display_editor.set_render_diff_hunk_controls(
374 Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
375 cx,
376 ),
377 }
378 diff_display_editor.rhs_editor().update(cx, |editor, cx| {
379 editor.disable_diagnostics(cx);
380 editor.set_show_diff_review_button(true, cx);
381
382 match branch_diff.read(cx).diff_base() {
383 DiffBase::Head => {
384 editor.register_addon(GitPanelAddon {
385 workspace: workspace.downgrade(),
386 });
387 }
388 DiffBase::Merge { .. } => {
389 editor.register_addon(BranchDiffAddon {
390 branch_diff: branch_diff.clone(),
391 });
392 editor.start_temporary_diff_override();
393 }
394 }
395 });
396 diff_display_editor
397 });
398 let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event);
399
400 let primary_editor = editor.read(cx).rhs_editor().clone();
401 let review_comment_subscription =
402 cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| {
403 if let EditorEvent::ReviewCommentsChanged { total_count } = event {
404 this.review_comment_count = *total_count;
405 cx.notify();
406 }
407 });
408
409 let branch_diff_subscription = cx.subscribe_in(
410 &branch_diff,
411 window,
412 move |this, _git_store, event, window, cx| match event {
413 BranchDiffEvent::FileListChanged => {
414 this._task = window.spawn(cx, {
415 let this = cx.weak_entity();
416 async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
417 })
418 }
419 },
420 );
421
422 let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
423 let mut was_collapse_untracked_diff =
424 GitPanelSettings::get_global(cx).collapse_untracked_diff;
425 cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
426 let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
427 let is_collapse_untracked_diff =
428 GitPanelSettings::get_global(cx).collapse_untracked_diff;
429 if is_sort_by_path != was_sort_by_path
430 || is_collapse_untracked_diff != was_collapse_untracked_diff
431 {
432 this._task = {
433 window.spawn(cx, {
434 let this = cx.weak_entity();
435 async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
436 })
437 }
438 }
439 was_sort_by_path = is_sort_by_path;
440 was_collapse_untracked_diff = is_collapse_untracked_diff;
441 })
442 .detach();
443
444 let task = window.spawn(cx, {
445 let this = cx.weak_entity();
446 async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
447 });
448
449 Self {
450 project,
451 workspace: workspace.downgrade(),
452 branch_diff,
453 focus_handle,
454 editor,
455 multibuffer,
456 buffer_diff_subscriptions: Default::default(),
457 pending_scroll: None,
458 review_comment_count: 0,
459 _task: task,
460 _subscription: Subscription::join(
461 branch_diff_subscription,
462 Subscription::join(editor_subscription, review_comment_subscription),
463 ),
464 }
465 }
466
467 pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
468 self.branch_diff.read(cx).diff_base()
469 }
470
471 pub fn move_to_entry(
472 &mut self,
473 entry: GitStatusEntry,
474 window: &mut Window,
475 cx: &mut Context<Self>,
476 ) {
477 let Some(git_repo) = self.branch_diff.read(cx).repo() else {
478 return;
479 };
480 let repo = git_repo.read(cx);
481 let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx);
482 let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
483
484 self.move_to_path(path_key, window, cx)
485 }
486
487 pub fn move_to_project_path(
488 &mut self,
489 project_path: &ProjectPath,
490 window: &mut Window,
491 cx: &mut Context<Self>,
492 ) {
493 let Some(git_repo) = self.branch_diff.read(cx).repo() else {
494 return;
495 };
496 let Some(repo_path) = git_repo
497 .read(cx)
498 .project_path_to_repo_path(project_path, cx)
499 else {
500 return;
501 };
502 let status = git_repo
503 .read(cx)
504 .status_for_path(&repo_path)
505 .map(|entry| entry.status)
506 .unwrap_or(FileStatus::Untracked);
507 let sort_prefix = sort_prefix(&git_repo.read(cx), &repo_path, status, cx);
508 let path_key = PathKey::with_sort_prefix(sort_prefix, repo_path.as_ref().clone());
509 self.move_to_path(path_key, window, cx)
510 }
511
512 pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
513 let editor = self.editor.read(cx).focused_editor().read(cx);
514 let position = editor.selections.newest_anchor().head();
515 let multi_buffer = editor.buffer().read(cx);
516 let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
517
518 let file = buffer.read(cx).file()?;
519 Some(ProjectPath {
520 worktree_id: file.worktree_id(cx),
521 path: file.path().clone(),
522 })
523 }
524
525 fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
526 self.editor.update(cx, |editor, cx| {
527 editor.rhs_editor().update(cx, |editor, cx| {
528 editor.move_to_beginning(&Default::default(), window, cx);
529 });
530 });
531 }
532
533 fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
534 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
535 self.editor.update(cx, |editor, cx| {
536 editor.rhs_editor().update(cx, |editor, cx| {
537 editor.change_selections(
538 SelectionEffects::scroll(Autoscroll::focused()),
539 window,
540 cx,
541 |s| {
542 s.select_ranges([position..position]);
543 },
544 )
545 })
546 });
547 } else {
548 self.pending_scroll = Some(path_key);
549 }
550 }
551
552 /// Returns the total count of review comments across all hunks/files.
553 pub fn total_review_comment_count(&self) -> usize {
554 self.review_comment_count
555 }
556
557 /// Returns a reference to the splittable editor.
558 pub fn editor(&self) -> &Entity<SplittableEditor> {
559 &self.editor
560 }
561
562 fn button_states(&self, cx: &App) -> ButtonStates {
563 let editor = self.editor.read(cx).rhs_editor().read(cx);
564 let snapshot = self.multibuffer.read(cx).snapshot(cx);
565 let prev_next = snapshot.diff_hunks().nth(1).is_some();
566 let mut selection = true;
567
568 let mut ranges = editor
569 .selections
570 .disjoint_anchor_ranges()
571 .collect::<Vec<_>>();
572 if !ranges.iter().any(|range| range.start != range.end) {
573 selection = false;
574 if let Some((excerpt_id, _, range)) = self
575 .editor
576 .read(cx)
577 .rhs_editor()
578 .read(cx)
579 .active_excerpt(cx)
580 {
581 ranges = vec![multi_buffer::Anchor::range_in_buffer(excerpt_id, range)];
582 } else {
583 ranges = Vec::default();
584 }
585 }
586 let mut has_staged_hunks = false;
587 let mut has_unstaged_hunks = false;
588 for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
589 match hunk.status.secondary {
590 DiffHunkSecondaryStatus::HasSecondaryHunk
591 | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
592 has_unstaged_hunks = true;
593 }
594 DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
595 has_staged_hunks = true;
596 has_unstaged_hunks = true;
597 }
598 DiffHunkSecondaryStatus::NoSecondaryHunk
599 | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
600 has_staged_hunks = true;
601 }
602 }
603 }
604 let mut stage_all = false;
605 let mut unstage_all = false;
606 self.workspace
607 .read_with(cx, |workspace, cx| {
608 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
609 let git_panel = git_panel.read(cx);
610 stage_all = git_panel.can_stage_all();
611 unstage_all = git_panel.can_unstage_all();
612 }
613 })
614 .ok();
615
616 ButtonStates {
617 stage: has_unstaged_hunks,
618 unstage: has_staged_hunks,
619 prev_next,
620 selection,
621 stage_all,
622 unstage_all,
623 }
624 }
625
626 fn handle_editor_event(
627 &mut self,
628 editor: &Entity<SplittableEditor>,
629 event: &EditorEvent,
630 window: &mut Window,
631 cx: &mut Context<Self>,
632 ) {
633 match event {
634 EditorEvent::SelectionsChanged { local: true } => {
635 let Some(project_path) = self.active_path(cx) else {
636 return;
637 };
638 self.workspace
639 .update(cx, |workspace, cx| {
640 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
641 git_panel.update(cx, |git_panel, cx| {
642 git_panel.select_entry_by_path(project_path, window, cx)
643 })
644 }
645 })
646 .ok();
647 }
648 EditorEvent::Saved => {
649 self._task = cx.spawn_in(window, async move |this, cx| {
650 Self::refresh(this, RefreshReason::EditorSaved, cx).await
651 });
652 }
653 _ => {}
654 }
655 if editor.focus_handle(cx).contains_focused(window, cx)
656 && self.multibuffer.read(cx).is_empty()
657 {
658 self.focus_handle.focus(window, cx)
659 }
660 }
661
662 #[instrument(skip_all)]
663 fn register_buffer(
664 &mut self,
665 path_key: PathKey,
666 file_status: FileStatus,
667 buffer: Entity<Buffer>,
668 diff: Entity<BufferDiff>,
669 window: &mut Window,
670 cx: &mut Context<Self>,
671 ) -> Option<BufferId> {
672 let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
673 this._task = window.spawn(cx, {
674 let this = cx.weak_entity();
675 async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await
676 })
677 });
678 self.buffer_diff_subscriptions
679 .insert(path_key.path.clone(), (diff.clone(), subscription));
680
681 // TODO(split-diff) we shouldn't have a conflict addon when split
682 let conflict_addon = self
683 .editor
684 .read(cx)
685 .rhs_editor()
686 .read(cx)
687 .addon::<ConflictAddon>()
688 .expect("project diff editor should have a conflict addon");
689
690 let snapshot = buffer.read(cx).snapshot();
691 let diff_snapshot = diff.read(cx).snapshot(cx);
692
693 let excerpt_ranges = {
694 let diff_hunk_ranges = diff_snapshot
695 .hunks_intersecting_range(
696 Anchor::min_max_range_for_buffer(snapshot.remote_id()),
697 &snapshot,
698 )
699 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
700 let conflicts = conflict_addon
701 .conflict_set(snapshot.remote_id())
702 .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts)
703 .unwrap_or_default();
704 let mut conflicts = conflicts
705 .iter()
706 .map(|conflict| conflict.range.to_point(&snapshot))
707 .peekable();
708
709 if conflicts.peek().is_some() {
710 conflicts.collect::<Vec<_>>()
711 } else {
712 diff_hunk_ranges.collect()
713 }
714 };
715
716 let mut needs_fold = None;
717
718 let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
719 let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
720 let (_, is_newly_added) = editor.set_excerpts_for_path(
721 path_key.clone(),
722 buffer,
723 excerpt_ranges,
724 multibuffer_context_lines(cx),
725 diff,
726 cx,
727 );
728 (was_empty, is_newly_added)
729 });
730
731 self.editor.update(cx, |editor, cx| {
732 editor.rhs_editor().update(cx, |editor, cx| {
733 if was_empty {
734 editor.change_selections(
735 SelectionEffects::no_scroll(),
736 window,
737 cx,
738 |selections| {
739 selections.select_ranges([
740 multi_buffer::Anchor::min()..multi_buffer::Anchor::min()
741 ])
742 },
743 );
744 }
745 if is_excerpt_newly_added
746 && (file_status.is_deleted()
747 || (file_status.is_untracked()
748 && GitPanelSettings::get_global(cx).collapse_untracked_diff))
749 {
750 needs_fold = Some(snapshot.text.remote_id());
751 }
752 })
753 });
754
755 if self.multibuffer.read(cx).is_empty()
756 && self
757 .editor
758 .read(cx)
759 .focus_handle(cx)
760 .contains_focused(window, cx)
761 {
762 self.focus_handle.focus(window, cx);
763 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
764 self.editor.update(cx, |editor, cx| {
765 editor.focus_handle(cx).focus(window, cx);
766 });
767 }
768 if self.pending_scroll.as_ref() == Some(&path_key) {
769 self.move_to_path(path_key, window, cx);
770 }
771
772 needs_fold
773 }
774
775 #[instrument(skip_all)]
776 pub async fn refresh(
777 this: WeakEntity<Self>,
778 reason: RefreshReason,
779 cx: &mut AsyncWindowContext,
780 ) -> Result<()> {
781 let mut path_keys = Vec::new();
782 let buffers_to_load = this.update(cx, |this, cx| {
783 let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
784 let load_buffers = branch_diff.load_buffers(cx);
785 (branch_diff.repo().cloned(), load_buffers)
786 });
787 let mut previous_paths = this
788 .multibuffer
789 .read(cx)
790 .paths()
791 .cloned()
792 .collect::<HashSet<_>>();
793
794 if let Some(repo) = repo {
795 let repo = repo.read(cx);
796
797 path_keys = Vec::with_capacity(buffers_to_load.len());
798 for entry in buffers_to_load.iter() {
799 let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx);
800 let path_key =
801 PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
802 previous_paths.remove(&path_key);
803 path_keys.push(path_key)
804 }
805 }
806
807 this.editor.update(cx, |editor, cx| {
808 for path in previous_paths {
809 if let Some(buffer) = this.multibuffer.read(cx).buffer_for_path(&path, cx) {
810 let skip = match reason {
811 RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
812 buffer.read(cx).is_dirty()
813 }
814 RefreshReason::StatusesChanged => false,
815 };
816 if skip {
817 continue;
818 }
819 }
820
821 this.buffer_diff_subscriptions.remove(&path.path);
822 editor.remove_excerpts_for_path(path, cx);
823 }
824 });
825 buffers_to_load
826 })?;
827
828 let mut buffers_to_fold = Vec::new();
829
830 for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) {
831 if let Some((buffer, diff)) = entry.load.await.log_err() {
832 // We might be lagging behind enough that all future entry.load futures are no longer pending.
833 // If that is the case, this task will never yield, starving the foreground thread of execution time.
834 yield_now().await;
835 cx.update(|window, cx| {
836 this.update(cx, |this, cx| {
837 let multibuffer = this.multibuffer.read(cx);
838 let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some()
839 && multibuffer
840 .diff_for(buffer.read(cx).remote_id())
841 .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id())
842 && match reason {
843 RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
844 buffer.read(cx).is_dirty()
845 }
846 RefreshReason::StatusesChanged => false,
847 };
848 if !skip {
849 if let Some(buffer_id) = this.register_buffer(
850 path_key,
851 entry.file_status,
852 buffer,
853 diff,
854 window,
855 cx,
856 ) {
857 buffers_to_fold.push(buffer_id);
858 }
859 }
860 })
861 .ok();
862 })?;
863 }
864 }
865 this.update(cx, |this, cx| {
866 if !buffers_to_fold.is_empty() {
867 this.editor.update(cx, |editor, cx| {
868 editor
869 .rhs_editor()
870 .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
871 });
872 }
873 this.pending_scroll.take();
874 cx.notify();
875 })?;
876
877 Ok(())
878 }
879
880 #[cfg(any(test, feature = "test-support"))]
881 pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
882 self.multibuffer
883 .read(cx)
884 .paths()
885 .map(|key| key.path.clone())
886 .collect()
887 }
888}
889
890fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
891 let settings = GitPanelSettings::get_global(cx);
892
893 if settings.sort_by_path && !settings.tree_view {
894 TRACKED_SORT_PREFIX
895 } else if repo.had_conflict_on_last_merge_head_change(repo_path) {
896 CONFLICT_SORT_PREFIX
897 } else if status.is_created() {
898 NEW_SORT_PREFIX
899 } else {
900 TRACKED_SORT_PREFIX
901 }
902}
903
904impl EventEmitter<EditorEvent> for ProjectDiff {}
905
906impl Focusable for ProjectDiff {
907 fn focus_handle(&self, cx: &App) -> FocusHandle {
908 if self.multibuffer.read(cx).is_empty() {
909 self.focus_handle.clone()
910 } else {
911 self.editor.focus_handle(cx)
912 }
913 }
914}
915
916impl Item for ProjectDiff {
917 type Event = EditorEvent;
918
919 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
920 Some(Icon::new(IconName::GitBranch).color(Color::Muted))
921 }
922
923 fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
924 Editor::to_item_events(event, f)
925 }
926
927 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
928 self.editor.update(cx, |editor, cx| {
929 editor.rhs_editor().update(cx, |primary_editor, cx| {
930 primary_editor.deactivated(window, cx);
931 })
932 });
933 }
934
935 fn navigate(
936 &mut self,
937 data: Arc<dyn Any + Send>,
938 window: &mut Window,
939 cx: &mut Context<Self>,
940 ) -> bool {
941 self.editor.update(cx, |editor, cx| {
942 editor.rhs_editor().update(cx, |primary_editor, cx| {
943 primary_editor.navigate(data, window, cx)
944 })
945 })
946 }
947
948 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
949 Some("Project Diff".into())
950 }
951
952 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
953 Label::new(self.tab_content_text(0, cx))
954 .color(if params.selected {
955 Color::Default
956 } else {
957 Color::Muted
958 })
959 .into_any_element()
960 }
961
962 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
963 match self.branch_diff.read(cx).diff_base() {
964 DiffBase::Head => "Uncommitted Changes".into(),
965 DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
966 }
967 }
968
969 fn telemetry_event_text(&self) -> Option<&'static str> {
970 Some("Project Diff Opened")
971 }
972
973 fn as_searchable(&self, _: &Entity<Self>, _cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
974 Some(Box::new(self.editor.clone()))
975 }
976
977 fn for_each_project_item(
978 &self,
979 cx: &App,
980 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
981 ) {
982 self.editor
983 .read(cx)
984 .rhs_editor()
985 .read(cx)
986 .for_each_project_item(cx, f)
987 }
988
989 fn set_nav_history(
990 &mut self,
991 nav_history: ItemNavHistory,
992 _: &mut Window,
993 cx: &mut Context<Self>,
994 ) {
995 self.editor.update(cx, |editor, cx| {
996 editor.rhs_editor().update(cx, |primary_editor, _| {
997 primary_editor.set_nav_history(Some(nav_history));
998 })
999 });
1000 }
1001
1002 fn can_split(&self) -> bool {
1003 true
1004 }
1005
1006 fn clone_on_split(
1007 &self,
1008 _workspace_id: Option<workspace::WorkspaceId>,
1009 window: &mut Window,
1010 cx: &mut Context<Self>,
1011 ) -> Task<Option<Entity<Self>>>
1012 where
1013 Self: Sized,
1014 {
1015 let Some(workspace) = self.workspace.upgrade() else {
1016 return Task::ready(None);
1017 };
1018 Task::ready(Some(cx.new(|cx| {
1019 ProjectDiff::new(self.project.clone(), workspace, window, cx)
1020 })))
1021 }
1022
1023 fn is_dirty(&self, cx: &App) -> bool {
1024 self.multibuffer.read(cx).is_dirty(cx)
1025 }
1026
1027 fn has_conflict(&self, cx: &App) -> bool {
1028 self.multibuffer.read(cx).has_conflict(cx)
1029 }
1030
1031 fn can_save(&self, _: &App) -> bool {
1032 true
1033 }
1034
1035 fn save(
1036 &mut self,
1037 options: SaveOptions,
1038 project: Entity<Project>,
1039 window: &mut Window,
1040 cx: &mut Context<Self>,
1041 ) -> Task<Result<()>> {
1042 self.editor.update(cx, |editor, cx| {
1043 editor.rhs_editor().update(cx, |primary_editor, cx| {
1044 primary_editor.save(options, project, window, cx)
1045 })
1046 })
1047 }
1048
1049 fn save_as(
1050 &mut self,
1051 _: Entity<Project>,
1052 _: ProjectPath,
1053 _window: &mut Window,
1054 _: &mut Context<Self>,
1055 ) -> Task<Result<()>> {
1056 unreachable!()
1057 }
1058
1059 fn reload(
1060 &mut self,
1061 project: Entity<Project>,
1062 window: &mut Window,
1063 cx: &mut Context<Self>,
1064 ) -> Task<Result<()>> {
1065 self.editor.update(cx, |editor, cx| {
1066 editor.rhs_editor().update(cx, |primary_editor, cx| {
1067 primary_editor.reload(project, window, cx)
1068 })
1069 })
1070 }
1071
1072 fn act_as_type<'a>(
1073 &'a self,
1074 type_id: TypeId,
1075 self_handle: &'a Entity<Self>,
1076 cx: &'a App,
1077 ) -> Option<gpui::AnyEntity> {
1078 if type_id == TypeId::of::<Self>() {
1079 Some(self_handle.clone().into())
1080 } else if type_id == TypeId::of::<Editor>() {
1081 Some(self.editor.read(cx).rhs_editor().clone().into())
1082 } else if type_id == TypeId::of::<SplittableEditor>() {
1083 Some(self.editor.clone().into())
1084 } else {
1085 None
1086 }
1087 }
1088
1089 fn added_to_workspace(
1090 &mut self,
1091 workspace: &mut Workspace,
1092 window: &mut Window,
1093 cx: &mut Context<Self>,
1094 ) {
1095 self.editor.update(cx, |editor, cx| {
1096 editor.added_to_workspace(workspace, window, cx)
1097 });
1098 }
1099}
1100
1101impl Render for ProjectDiff {
1102 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1103 let is_empty = self.multibuffer.read(cx).is_empty();
1104
1105 div()
1106 .track_focus(&self.focus_handle)
1107 .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
1108 .bg(cx.theme().colors().editor_background)
1109 .flex()
1110 .items_center()
1111 .justify_center()
1112 .size_full()
1113 .when(is_empty, |el| {
1114 let remote_button = if let Some(panel) = self
1115 .workspace
1116 .upgrade()
1117 .and_then(|workspace| workspace.read(cx).panel::<GitPanel>(cx))
1118 {
1119 panel.update(cx, |panel, cx| panel.render_remote_button(cx))
1120 } else {
1121 None
1122 };
1123 let keybinding_focus_handle = self.focus_handle(cx);
1124 el.child(
1125 v_flex()
1126 .gap_1()
1127 .child(
1128 h_flex()
1129 .justify_around()
1130 .child(Label::new("No uncommitted changes")),
1131 )
1132 .map(|el| match remote_button {
1133 Some(button) => el.child(h_flex().justify_around().child(button)),
1134 None => el.child(
1135 h_flex()
1136 .justify_around()
1137 .child(Label::new("Remote up to date")),
1138 ),
1139 })
1140 .child(
1141 h_flex().justify_around().mt_1().child(
1142 Button::new("project-diff-close-button", "Close")
1143 // .style(ButtonStyle::Transparent)
1144 .key_binding(KeyBinding::for_action_in(
1145 &CloseActiveItem::default(),
1146 &keybinding_focus_handle,
1147 cx,
1148 ))
1149 .on_click(move |_, window, cx| {
1150 window.focus(&keybinding_focus_handle, cx);
1151 window.dispatch_action(
1152 Box::new(CloseActiveItem::default()),
1153 cx,
1154 );
1155 }),
1156 ),
1157 ),
1158 )
1159 })
1160 .when(!is_empty, |el| el.child(self.editor.clone()))
1161 }
1162}
1163
1164impl SerializableItem for ProjectDiff {
1165 fn serialized_item_kind() -> &'static str {
1166 "ProjectDiff"
1167 }
1168
1169 fn cleanup(
1170 _: workspace::WorkspaceId,
1171 _: Vec<workspace::ItemId>,
1172 _: &mut Window,
1173 _: &mut App,
1174 ) -> Task<Result<()>> {
1175 Task::ready(Ok(()))
1176 }
1177
1178 fn deserialize(
1179 project: Entity<Project>,
1180 workspace: WeakEntity<Workspace>,
1181 workspace_id: workspace::WorkspaceId,
1182 item_id: workspace::ItemId,
1183 window: &mut Window,
1184 cx: &mut App,
1185 ) -> Task<Result<Entity<Self>>> {
1186 window.spawn(cx, async move |cx| {
1187 let diff_base = persistence::PROJECT_DIFF_DB.get_diff_base(item_id, workspace_id)?;
1188
1189 let diff = cx.update(|window, cx| {
1190 let branch_diff = cx
1191 .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx));
1192 let workspace = workspace.upgrade().context("workspace gone")?;
1193 anyhow::Ok(
1194 cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
1195 )
1196 })??;
1197
1198 Ok(diff)
1199 })
1200 }
1201
1202 fn serialize(
1203 &mut self,
1204 workspace: &mut Workspace,
1205 item_id: workspace::ItemId,
1206 _closing: bool,
1207 _window: &mut Window,
1208 cx: &mut Context<Self>,
1209 ) -> Option<Task<Result<()>>> {
1210 let workspace_id = workspace.database_id()?;
1211 let diff_base = self.diff_base(cx).clone();
1212
1213 Some(cx.background_spawn({
1214 async move {
1215 persistence::PROJECT_DIFF_DB
1216 .save_diff_base(item_id, workspace_id, diff_base.clone())
1217 .await
1218 }
1219 }))
1220 }
1221
1222 fn should_serialize(&self, _: &Self::Event) -> bool {
1223 false
1224 }
1225}
1226
1227mod persistence {
1228
1229 use anyhow::Context as _;
1230 use db::{
1231 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
1232 sqlez_macros::sql,
1233 };
1234 use project::git_store::branch_diff::DiffBase;
1235 use workspace::{ItemId, WorkspaceDb, WorkspaceId};
1236
1237 pub struct ProjectDiffDb(ThreadSafeConnection);
1238
1239 impl Domain for ProjectDiffDb {
1240 const NAME: &str = stringify!(ProjectDiffDb);
1241
1242 const MIGRATIONS: &[&str] = &[sql!(
1243 CREATE TABLE project_diffs(
1244 workspace_id INTEGER,
1245 item_id INTEGER UNIQUE,
1246
1247 diff_base TEXT,
1248
1249 PRIMARY KEY(workspace_id, item_id),
1250 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
1251 ON DELETE CASCADE
1252 ) STRICT;
1253 )];
1254 }
1255
1256 db::static_connection!(PROJECT_DIFF_DB, ProjectDiffDb, [WorkspaceDb]);
1257
1258 impl ProjectDiffDb {
1259 pub async fn save_diff_base(
1260 &self,
1261 item_id: ItemId,
1262 workspace_id: WorkspaceId,
1263 diff_base: DiffBase,
1264 ) -> anyhow::Result<()> {
1265 self.write(move |connection| {
1266 let sql_stmt = sql!(
1267 INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?)
1268 );
1269 let diff_base_str = serde_json::to_string(&diff_base)?;
1270 let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?;
1271 query((item_id, workspace_id, diff_base_str)).context(format!(
1272 "exec_bound failed to execute or parse for: {}",
1273 sql_stmt
1274 ))
1275 })
1276 .await
1277 }
1278
1279 pub fn get_diff_base(
1280 &self,
1281 item_id: ItemId,
1282 workspace_id: WorkspaceId,
1283 ) -> anyhow::Result<DiffBase> {
1284 let sql_stmt =
1285 sql!(SELECT diff_base FROM project_diffs WHERE item_id = ?AND workspace_id = ?);
1286 let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?(
1287 (item_id, workspace_id),
1288 )
1289 .context(::std::format!(
1290 "Error in get_diff_base, select_row_bound failed to execute or parse for: {}",
1291 sql_stmt
1292 ))?;
1293 let Some(diff_base_str) = diff_base_str else {
1294 return Ok(DiffBase::Head);
1295 };
1296 serde_json::from_str(&diff_base_str).context("deserializing diff base")
1297 }
1298 }
1299}
1300
1301pub struct ProjectDiffToolbar {
1302 project_diff: Option<WeakEntity<ProjectDiff>>,
1303 workspace: WeakEntity<Workspace>,
1304}
1305
1306impl ProjectDiffToolbar {
1307 pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
1308 Self {
1309 project_diff: None,
1310 workspace: workspace.weak_handle(),
1311 }
1312 }
1313
1314 fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
1315 self.project_diff.as_ref()?.upgrade()
1316 }
1317
1318 fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
1319 if let Some(project_diff) = self.project_diff(cx) {
1320 project_diff.focus_handle(cx).focus(window, cx);
1321 }
1322 let action = action.boxed_clone();
1323 cx.defer(move |cx| {
1324 cx.dispatch_action(action.as_ref());
1325 })
1326 }
1327
1328 fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1329 self.workspace
1330 .update(cx, |workspace, cx| {
1331 if let Some(panel) = workspace.panel::<GitPanel>(cx) {
1332 panel.update(cx, |panel, cx| {
1333 panel.stage_all(&Default::default(), window, cx);
1334 });
1335 }
1336 })
1337 .ok();
1338 }
1339
1340 fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1341 self.workspace
1342 .update(cx, |workspace, cx| {
1343 let Some(panel) = workspace.panel::<GitPanel>(cx) else {
1344 return;
1345 };
1346 panel.update(cx, |panel, cx| {
1347 panel.unstage_all(&Default::default(), window, cx);
1348 });
1349 })
1350 .ok();
1351 }
1352}
1353
1354impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
1355
1356impl ToolbarItemView for ProjectDiffToolbar {
1357 fn set_active_pane_item(
1358 &mut self,
1359 active_pane_item: Option<&dyn ItemHandle>,
1360 _: &mut Window,
1361 cx: &mut Context<Self>,
1362 ) -> ToolbarItemLocation {
1363 self.project_diff = active_pane_item
1364 .and_then(|item| item.act_as::<ProjectDiff>(cx))
1365 .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head)
1366 .map(|entity| entity.downgrade());
1367 if self.project_diff.is_some() {
1368 ToolbarItemLocation::PrimaryRight
1369 } else {
1370 ToolbarItemLocation::Hidden
1371 }
1372 }
1373
1374 fn pane_focus_update(
1375 &mut self,
1376 _pane_focused: bool,
1377 _window: &mut Window,
1378 _cx: &mut Context<Self>,
1379 ) {
1380 }
1381}
1382
1383struct ButtonStates {
1384 stage: bool,
1385 unstage: bool,
1386 prev_next: bool,
1387 selection: bool,
1388 stage_all: bool,
1389 unstage_all: bool,
1390}
1391
1392impl Render for ProjectDiffToolbar {
1393 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1394 let Some(project_diff) = self.project_diff(cx) else {
1395 return div();
1396 };
1397 let focus_handle = project_diff.focus_handle(cx);
1398 let button_states = project_diff.read(cx).button_states(cx);
1399 let review_count = project_diff.read(cx).total_review_comment_count();
1400
1401 h_group_xl()
1402 .my_neg_1()
1403 .py_1()
1404 .items_center()
1405 .flex_wrap()
1406 .justify_between()
1407 .child(
1408 h_group_sm()
1409 .when(button_states.selection, |el| {
1410 el.child(
1411 Button::new("stage", "Toggle Staged")
1412 .tooltip(Tooltip::for_action_title_in(
1413 "Toggle Staged",
1414 &ToggleStaged,
1415 &focus_handle,
1416 ))
1417 .disabled(!button_states.stage && !button_states.unstage)
1418 .on_click(cx.listener(|this, _, window, cx| {
1419 this.dispatch_action(&ToggleStaged, window, cx)
1420 })),
1421 )
1422 })
1423 .when(!button_states.selection, |el| {
1424 el.child(
1425 Button::new("stage", "Stage")
1426 .tooltip(Tooltip::for_action_title_in(
1427 "Stage and go to next hunk",
1428 &StageAndNext,
1429 &focus_handle,
1430 ))
1431 .disabled(
1432 !button_states.prev_next
1433 && !button_states.stage_all
1434 && !button_states.unstage_all,
1435 )
1436 .on_click(cx.listener(|this, _, window, cx| {
1437 this.dispatch_action(&StageAndNext, window, cx)
1438 })),
1439 )
1440 .child(
1441 Button::new("unstage", "Unstage")
1442 .tooltip(Tooltip::for_action_title_in(
1443 "Unstage and go to next hunk",
1444 &UnstageAndNext,
1445 &focus_handle,
1446 ))
1447 .disabled(
1448 !button_states.prev_next
1449 && !button_states.stage_all
1450 && !button_states.unstage_all,
1451 )
1452 .on_click(cx.listener(|this, _, window, cx| {
1453 this.dispatch_action(&UnstageAndNext, window, cx)
1454 })),
1455 )
1456 }),
1457 )
1458 // n.b. the only reason these arrows are here is because we don't
1459 // support "undo" for staging so we need a way to go back.
1460 .child(
1461 h_group_sm()
1462 .child(
1463 IconButton::new("up", IconName::ArrowUp)
1464 .shape(ui::IconButtonShape::Square)
1465 .tooltip(Tooltip::for_action_title_in(
1466 "Go to previous hunk",
1467 &GoToPreviousHunk,
1468 &focus_handle,
1469 ))
1470 .disabled(!button_states.prev_next)
1471 .on_click(cx.listener(|this, _, window, cx| {
1472 this.dispatch_action(&GoToPreviousHunk, window, cx)
1473 })),
1474 )
1475 .child(
1476 IconButton::new("down", IconName::ArrowDown)
1477 .shape(ui::IconButtonShape::Square)
1478 .tooltip(Tooltip::for_action_title_in(
1479 "Go to next hunk",
1480 &GoToHunk,
1481 &focus_handle,
1482 ))
1483 .disabled(!button_states.prev_next)
1484 .on_click(cx.listener(|this, _, window, cx| {
1485 this.dispatch_action(&GoToHunk, window, cx)
1486 })),
1487 ),
1488 )
1489 .child(vertical_divider())
1490 .child(
1491 h_group_sm()
1492 .when(
1493 button_states.unstage_all && !button_states.stage_all,
1494 |el| {
1495 el.child(
1496 Button::new("unstage-all", "Unstage All")
1497 .tooltip(Tooltip::for_action_title_in(
1498 "Unstage all changes",
1499 &UnstageAll,
1500 &focus_handle,
1501 ))
1502 .on_click(cx.listener(|this, _, window, cx| {
1503 this.unstage_all(window, cx)
1504 })),
1505 )
1506 },
1507 )
1508 .when(
1509 !button_states.unstage_all || button_states.stage_all,
1510 |el| {
1511 el.child(
1512 // todo make it so that changing to say "Unstaged"
1513 // doesn't change the position.
1514 div().child(
1515 Button::new("stage-all", "Stage All")
1516 .disabled(!button_states.stage_all)
1517 .tooltip(Tooltip::for_action_title_in(
1518 "Stage all changes",
1519 &StageAll,
1520 &focus_handle,
1521 ))
1522 .on_click(cx.listener(|this, _, window, cx| {
1523 this.stage_all(window, cx)
1524 })),
1525 ),
1526 )
1527 },
1528 )
1529 .child(
1530 Button::new("commit", "Commit")
1531 .tooltip(Tooltip::for_action_title_in(
1532 "Commit",
1533 &Commit,
1534 &focus_handle,
1535 ))
1536 .on_click(cx.listener(|this, _, window, cx| {
1537 this.dispatch_action(&Commit, window, cx);
1538 })),
1539 ),
1540 )
1541 // "Send Review to Agent" button (only shown when there are review comments)
1542 .when(review_count > 0, |el| {
1543 el.child(vertical_divider()).child(
1544 render_send_review_to_agent_button(review_count, &focus_handle).on_click(
1545 cx.listener(|this, _, window, cx| {
1546 this.dispatch_action(&SendReviewToAgent, window, cx)
1547 }),
1548 ),
1549 )
1550 })
1551 }
1552}
1553
1554fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusHandle) -> Button {
1555 Button::new(
1556 "send-review",
1557 format!("Send Review to Agent ({})", review_count),
1558 )
1559 .icon(IconName::ZedAssistant)
1560 .icon_position(IconPosition::Start)
1561 .tooltip(Tooltip::for_action_title_in(
1562 "Send all review comments to the Agent panel",
1563 &SendReviewToAgent,
1564 focus_handle,
1565 ))
1566}
1567
1568pub struct BranchDiffToolbar {
1569 project_diff: Option<WeakEntity<ProjectDiff>>,
1570}
1571
1572impl BranchDiffToolbar {
1573 pub fn new(_cx: &mut Context<Self>) -> Self {
1574 Self { project_diff: None }
1575 }
1576
1577 fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
1578 self.project_diff.as_ref()?.upgrade()
1579 }
1580
1581 fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
1582 if let Some(project_diff) = self.project_diff(cx) {
1583 project_diff.focus_handle(cx).focus(window, cx);
1584 }
1585 let action = action.boxed_clone();
1586 cx.defer(move |cx| {
1587 cx.dispatch_action(action.as_ref());
1588 })
1589 }
1590}
1591
1592impl EventEmitter<ToolbarItemEvent> for BranchDiffToolbar {}
1593
1594impl ToolbarItemView for BranchDiffToolbar {
1595 fn set_active_pane_item(
1596 &mut self,
1597 active_pane_item: Option<&dyn ItemHandle>,
1598 _: &mut Window,
1599 cx: &mut Context<Self>,
1600 ) -> ToolbarItemLocation {
1601 self.project_diff = active_pane_item
1602 .and_then(|item| item.act_as::<ProjectDiff>(cx))
1603 .filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }))
1604 .map(|entity| entity.downgrade());
1605 if self.project_diff.is_some() {
1606 ToolbarItemLocation::PrimaryRight
1607 } else {
1608 ToolbarItemLocation::Hidden
1609 }
1610 }
1611
1612 fn pane_focus_update(
1613 &mut self,
1614 _pane_focused: bool,
1615 _window: &mut Window,
1616 _cx: &mut Context<Self>,
1617 ) {
1618 }
1619}
1620
1621impl Render for BranchDiffToolbar {
1622 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1623 let Some(project_diff) = self.project_diff(cx) else {
1624 return div();
1625 };
1626 let focus_handle = project_diff.focus_handle(cx);
1627 let review_count = project_diff.read(cx).total_review_comment_count();
1628
1629 let show_review_button = AgentSettings::get_global(cx).enabled(cx);
1630
1631 h_group_xl()
1632 .my_neg_1()
1633 .py_1()
1634 .items_center()
1635 .flex_wrap()
1636 .justify_end()
1637 .when(show_review_button, |this| {
1638 let focus_handle = focus_handle.clone();
1639 this.child(
1640 Button::new("review-diff", "Review Diff")
1641 .icon(IconName::ZedAssistant)
1642 .icon_position(IconPosition::Start)
1643 .icon_size(IconSize::Small)
1644 .icon_color(Color::Muted)
1645 .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx))
1646 .tooltip(move |_, cx| {
1647 Tooltip::with_meta_in(
1648 "Review Diff",
1649 Some(&ReviewDiff),
1650 "Send this diff for your last agent to review.",
1651 &focus_handle,
1652 cx,
1653 )
1654 })
1655 .on_click(cx.listener(|this, _, window, cx| {
1656 this.dispatch_action(&ReviewDiff, window, cx);
1657 })),
1658 )
1659 })
1660 .when(review_count > 0, |this| {
1661 this.child(vertical_divider()).child(
1662 render_send_review_to_agent_button(review_count, &focus_handle).on_click(
1663 cx.listener(|this, _, window, cx| {
1664 this.dispatch_action(&SendReviewToAgent, window, cx)
1665 }),
1666 ),
1667 )
1668 })
1669 }
1670}
1671
1672#[derive(IntoElement, RegisterComponent)]
1673pub struct ProjectDiffEmptyState {
1674 pub no_repo: bool,
1675 pub can_push_and_pull: bool,
1676 pub focus_handle: Option<FocusHandle>,
1677 pub current_branch: Option<Branch>,
1678 // has_pending_commits: bool,
1679 // ahead_of_remote: bool,
1680 // no_git_repository: bool,
1681}
1682
1683impl RenderOnce for ProjectDiffEmptyState {
1684 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
1685 let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
1686 matches!(self.current_branch, Some(Branch {
1687 upstream:
1688 Some(Upstream {
1689 tracking:
1690 UpstreamTracking::Tracked(UpstreamTrackingStatus {
1691 ahead, behind, ..
1692 }),
1693 ..
1694 }),
1695 ..
1696 }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0))
1697 };
1698
1699 let change_count = |current_branch: &Branch| -> (usize, usize) {
1700 match current_branch {
1701 Branch {
1702 upstream:
1703 Some(Upstream {
1704 tracking:
1705 UpstreamTracking::Tracked(UpstreamTrackingStatus {
1706 ahead, behind, ..
1707 }),
1708 ..
1709 }),
1710 ..
1711 } => (*ahead as usize, *behind as usize),
1712 _ => (0, 0),
1713 }
1714 };
1715
1716 let not_ahead_or_behind = status_against_remote(0, 0);
1717 let ahead_of_remote = status_against_remote(1, 0);
1718 let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
1719 branch.upstream.is_none()
1720 } else {
1721 false
1722 };
1723
1724 let has_branch_container = |branch: &Branch| {
1725 h_flex()
1726 .max_w(px(420.))
1727 .bg(cx.theme().colors().text.opacity(0.05))
1728 .border_1()
1729 .border_color(cx.theme().colors().border)
1730 .rounded_sm()
1731 .gap_8()
1732 .px_6()
1733 .py_4()
1734 .map(|this| {
1735 if ahead_of_remote {
1736 let ahead_count = change_count(branch).0;
1737 let ahead_string = format!("{} Commits Ahead", ahead_count);
1738 this.child(
1739 v_flex()
1740 .child(Headline::new(ahead_string).size(HeadlineSize::Small))
1741 .child(
1742 Label::new(format!("Push your changes to {}", branch.name()))
1743 .color(Color::Muted),
1744 ),
1745 )
1746 .child(div().child(render_push_button(
1747 self.focus_handle,
1748 "push".into(),
1749 ahead_count as u32,
1750 )))
1751 } else if branch_not_on_remote {
1752 this.child(
1753 v_flex()
1754 .child(Headline::new("Publish Branch").size(HeadlineSize::Small))
1755 .child(
1756 Label::new(format!("Create {} on remote", branch.name()))
1757 .color(Color::Muted),
1758 ),
1759 )
1760 .child(
1761 div().child(render_publish_button(self.focus_handle, "publish".into())),
1762 )
1763 } else {
1764 this.child(Label::new("Remote status unknown").color(Color::Muted))
1765 }
1766 })
1767 };
1768
1769 v_flex().size_full().items_center().justify_center().child(
1770 v_flex()
1771 .gap_1()
1772 .when(self.no_repo, |this| {
1773 this.text_center()
1774 .child(Label::new("No Repository").color(Color::Muted))
1775 .child(
1776 Button::new("initialize-repo", "Initialize Repository")
1777 .on_click(move |_, _, cx| cx.dispatch_action(&git::Init)),
1778 )
1779 })
1780 .map(|this| {
1781 if not_ahead_or_behind && self.current_branch.is_some() {
1782 this.text_center()
1783 .child(Label::new("No Changes").color(Color::Muted))
1784 } else {
1785 this.when_some(self.current_branch.as_ref(), |this, branch| {
1786 this.child(has_branch_container(branch))
1787 })
1788 }
1789 }),
1790 )
1791 }
1792}
1793
1794mod preview {
1795 use git::repository::{
1796 Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
1797 };
1798 use ui::prelude::*;
1799
1800 use super::ProjectDiffEmptyState;
1801
1802 // View this component preview using `workspace: open component-preview`
1803 impl Component for ProjectDiffEmptyState {
1804 fn scope() -> ComponentScope {
1805 ComponentScope::VersionControl
1806 }
1807
1808 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
1809 let unknown_upstream: Option<UpstreamTracking> = None;
1810 let ahead_of_upstream: Option<UpstreamTracking> = Some(
1811 UpstreamTrackingStatus {
1812 ahead: 2,
1813 behind: 0,
1814 }
1815 .into(),
1816 );
1817
1818 let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
1819 UpstreamTrackingStatus {
1820 ahead: 0,
1821 behind: 0,
1822 }
1823 .into(),
1824 );
1825
1826 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
1827 Branch {
1828 is_head: true,
1829 ref_name: "some-branch".into(),
1830 upstream: upstream.map(|tracking| Upstream {
1831 ref_name: "origin/some-branch".into(),
1832 tracking,
1833 }),
1834 most_recent_commit: Some(CommitSummary {
1835 sha: "abc123".into(),
1836 subject: "Modify stuff".into(),
1837 commit_timestamp: 1710932954,
1838 author_name: "John Doe".into(),
1839 has_parent: true,
1840 }),
1841 }
1842 }
1843
1844 let no_repo_state = ProjectDiffEmptyState {
1845 no_repo: true,
1846 can_push_and_pull: false,
1847 focus_handle: None,
1848 current_branch: None,
1849 };
1850
1851 let no_changes_state = ProjectDiffEmptyState {
1852 no_repo: false,
1853 can_push_and_pull: true,
1854 focus_handle: None,
1855 current_branch: Some(branch(not_ahead_or_behind_upstream)),
1856 };
1857
1858 let ahead_of_upstream_state = ProjectDiffEmptyState {
1859 no_repo: false,
1860 can_push_and_pull: true,
1861 focus_handle: None,
1862 current_branch: Some(branch(ahead_of_upstream)),
1863 };
1864
1865 let unknown_upstream_state = ProjectDiffEmptyState {
1866 no_repo: false,
1867 can_push_and_pull: true,
1868 focus_handle: None,
1869 current_branch: Some(branch(unknown_upstream)),
1870 };
1871
1872 let (width, height) = (px(480.), px(320.));
1873
1874 Some(
1875 v_flex()
1876 .gap_6()
1877 .children(vec![
1878 example_group(vec![
1879 single_example(
1880 "No Repo",
1881 div()
1882 .w(width)
1883 .h(height)
1884 .child(no_repo_state)
1885 .into_any_element(),
1886 ),
1887 single_example(
1888 "No Changes",
1889 div()
1890 .w(width)
1891 .h(height)
1892 .child(no_changes_state)
1893 .into_any_element(),
1894 ),
1895 single_example(
1896 "Unknown Upstream",
1897 div()
1898 .w(width)
1899 .h(height)
1900 .child(unknown_upstream_state)
1901 .into_any_element(),
1902 ),
1903 single_example(
1904 "Ahead of Remote",
1905 div()
1906 .w(width)
1907 .h(height)
1908 .child(ahead_of_upstream_state)
1909 .into_any_element(),
1910 ),
1911 ])
1912 .vertical(),
1913 ])
1914 .into_any_element(),
1915 )
1916 }
1917 }
1918}
1919
1920struct BranchDiffAddon {
1921 branch_diff: Entity<branch_diff::BranchDiff>,
1922}
1923
1924impl Addon for BranchDiffAddon {
1925 fn to_any(&self) -> &dyn std::any::Any {
1926 self
1927 }
1928
1929 fn override_status_for_buffer_id(
1930 &self,
1931 buffer_id: language::BufferId,
1932 cx: &App,
1933 ) -> Option<FileStatus> {
1934 self.branch_diff
1935 .read(cx)
1936 .status_for_buffer_id(buffer_id, cx)
1937 }
1938}
1939
1940#[cfg(test)]
1941mod tests {
1942 use collections::HashMap;
1943 use db::indoc;
1944 use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
1945 use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
1946 use gpui::TestAppContext;
1947 use project::FakeFs;
1948 use serde_json::json;
1949 use settings::{DiffViewStyle, SettingsStore};
1950 use std::path::Path;
1951 use unindent::Unindent as _;
1952 use util::{
1953 path,
1954 rel_path::{RelPath, rel_path},
1955 };
1956
1957 use workspace::MultiWorkspace;
1958
1959 use super::*;
1960
1961 #[ctor::ctor]
1962 fn init_logger() {
1963 zlog::init_test();
1964 }
1965
1966 fn init_test(cx: &mut TestAppContext) {
1967 cx.update(|cx| {
1968 let store = SettingsStore::test(cx);
1969 cx.set_global(store);
1970 cx.update_global::<SettingsStore, _>(|store, cx| {
1971 store.update_user_settings(cx, |settings| {
1972 settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
1973 });
1974 });
1975 theme::init(theme::LoadThemes::JustBase, cx);
1976 editor::init(cx);
1977 crate::init(cx);
1978 });
1979 }
1980
1981 #[gpui::test]
1982 async fn test_save_after_restore(cx: &mut TestAppContext) {
1983 init_test(cx);
1984
1985 let fs = FakeFs::new(cx.executor());
1986 fs.insert_tree(
1987 path!("/project"),
1988 json!({
1989 ".git": {},
1990 "foo.txt": "FOO\n",
1991 }),
1992 )
1993 .await;
1994 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1995
1996 fs.set_head_for_repo(
1997 path!("/project/.git").as_ref(),
1998 &[("foo.txt", "foo\n".into())],
1999 "deadbeef",
2000 );
2001 fs.set_index_for_repo(
2002 path!("/project/.git").as_ref(),
2003 &[("foo.txt", "foo\n".into())],
2004 );
2005
2006 let (multi_workspace, cx) =
2007 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2008 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2009 let diff = cx.new_window_entity(|window, cx| {
2010 ProjectDiff::new(project.clone(), workspace, window, cx)
2011 });
2012 cx.run_until_parked();
2013
2014 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2015 assert_state_with_diff(
2016 &editor,
2017 cx,
2018 &"
2019 - ˇfoo
2020 + FOO
2021 "
2022 .unindent(),
2023 );
2024
2025 editor
2026 .update_in(cx, |editor, window, cx| {
2027 editor.git_restore(&Default::default(), window, cx);
2028 editor.save(SaveOptions::default(), project.clone(), window, cx)
2029 })
2030 .await
2031 .unwrap();
2032 cx.run_until_parked();
2033
2034 assert_state_with_diff(&editor, cx, &"ˇ".unindent());
2035
2036 let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
2037 assert_eq!(text, "foo\n");
2038 }
2039
2040 #[gpui::test]
2041 async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
2042 init_test(cx);
2043
2044 let fs = FakeFs::new(cx.executor());
2045 fs.insert_tree(
2046 path!("/project"),
2047 json!({
2048 ".git": {},
2049 "bar": "BAR\n",
2050 "foo": "FOO\n",
2051 }),
2052 )
2053 .await;
2054 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2055 let (multi_workspace, cx) =
2056 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2057 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2058 let diff = cx.new_window_entity(|window, cx| {
2059 ProjectDiff::new(project.clone(), workspace, window, cx)
2060 });
2061 cx.run_until_parked();
2062
2063 fs.set_head_and_index_for_repo(
2064 path!("/project/.git").as_ref(),
2065 &[("bar", "bar\n".into()), ("foo", "foo\n".into())],
2066 );
2067 cx.run_until_parked();
2068
2069 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
2070 diff.move_to_path(
2071 PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()),
2072 window,
2073 cx,
2074 );
2075 diff.editor.read(cx).rhs_editor().clone()
2076 });
2077 assert_state_with_diff(
2078 &editor,
2079 cx,
2080 &"
2081 - bar
2082 + BAR
2083
2084 - ˇfoo
2085 + FOO
2086 "
2087 .unindent(),
2088 );
2089
2090 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
2091 diff.move_to_path(
2092 PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()),
2093 window,
2094 cx,
2095 );
2096 diff.editor.read(cx).rhs_editor().clone()
2097 });
2098 assert_state_with_diff(
2099 &editor,
2100 cx,
2101 &"
2102 - ˇbar
2103 + BAR
2104
2105 - foo
2106 + FOO
2107 "
2108 .unindent(),
2109 );
2110 }
2111
2112 #[gpui::test]
2113 async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
2114 init_test(cx);
2115
2116 let fs = FakeFs::new(cx.executor());
2117 fs.insert_tree(
2118 path!("/project"),
2119 json!({
2120 ".git": {},
2121 "foo": "modified\n",
2122 }),
2123 )
2124 .await;
2125 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2126 let (multi_workspace, cx) =
2127 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2128 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2129 fs.set_head_for_repo(
2130 path!("/project/.git").as_ref(),
2131 &[("foo", "original\n".into())],
2132 "deadbeef",
2133 );
2134
2135 let buffer = project
2136 .update(cx, |project, cx| {
2137 project.open_local_buffer(path!("/project/foo"), cx)
2138 })
2139 .await
2140 .unwrap();
2141 let buffer_editor = cx.new_window_entity(|window, cx| {
2142 Editor::for_buffer(buffer, Some(project.clone()), window, cx)
2143 });
2144 let diff = cx.new_window_entity(|window, cx| {
2145 ProjectDiff::new(project.clone(), workspace, window, cx)
2146 });
2147 cx.run_until_parked();
2148
2149 let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2150
2151 assert_state_with_diff(
2152 &diff_editor,
2153 cx,
2154 &"
2155 - ˇoriginal
2156 + modified
2157 "
2158 .unindent(),
2159 );
2160
2161 let prev_buffer_hunks =
2162 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2163 let snapshot = buffer_editor.snapshot(window, cx);
2164 let snapshot = &snapshot.buffer_snapshot();
2165 let prev_buffer_hunks = buffer_editor
2166 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
2167 .collect::<Vec<_>>();
2168 buffer_editor.git_restore(&Default::default(), window, cx);
2169 prev_buffer_hunks
2170 });
2171 assert_eq!(prev_buffer_hunks.len(), 1);
2172 cx.run_until_parked();
2173
2174 let new_buffer_hunks =
2175 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2176 let snapshot = buffer_editor.snapshot(window, cx);
2177 let snapshot = &snapshot.buffer_snapshot();
2178 buffer_editor
2179 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
2180 .collect::<Vec<_>>()
2181 });
2182 assert_eq!(new_buffer_hunks.as_slice(), &[]);
2183
2184 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2185 buffer_editor.set_text("different\n", window, cx);
2186 buffer_editor.save(
2187 SaveOptions {
2188 format: false,
2189 autosave: false,
2190 },
2191 project.clone(),
2192 window,
2193 cx,
2194 )
2195 })
2196 .await
2197 .unwrap();
2198
2199 cx.run_until_parked();
2200
2201 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2202 buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
2203 });
2204
2205 assert_state_with_diff(
2206 &buffer_editor,
2207 cx,
2208 &"
2209 - original
2210 + different
2211 ˇ"
2212 .unindent(),
2213 );
2214
2215 assert_state_with_diff(
2216 &diff_editor,
2217 cx,
2218 &"
2219 - ˇoriginal
2220 + different
2221 "
2222 .unindent(),
2223 );
2224 }
2225
2226 use crate::{
2227 conflict_view::resolve_conflict,
2228 project_diff::{self, ProjectDiff},
2229 };
2230
2231 #[gpui::test]
2232 async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
2233 init_test(cx);
2234
2235 let fs = FakeFs::new(cx.executor());
2236 fs.insert_tree(
2237 path!("/a"),
2238 json!({
2239 ".git": {},
2240 "a.txt": "created\n",
2241 "b.txt": "really changed\n",
2242 "c.txt": "unchanged\n"
2243 }),
2244 )
2245 .await;
2246
2247 fs.set_head_and_index_for_repo(
2248 Path::new(path!("/a/.git")),
2249 &[
2250 ("b.txt", "before\n".to_string()),
2251 ("c.txt", "unchanged\n".to_string()),
2252 ("d.txt", "deleted\n".to_string()),
2253 ],
2254 );
2255
2256 let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2257 let (multi_workspace, cx) =
2258 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2259 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2260
2261 cx.run_until_parked();
2262
2263 cx.focus(&workspace);
2264 cx.update(|window, cx| {
2265 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2266 });
2267
2268 cx.run_until_parked();
2269
2270 let item = workspace.update(cx, |workspace, cx| {
2271 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2272 });
2273 cx.focus(&item);
2274 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2275
2276 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2277
2278 cx.assert_excerpts_with_selections(indoc!(
2279 "
2280 [EXCERPT]
2281 before
2282 really changed
2283 [EXCERPT]
2284 [FOLDED]
2285 [EXCERPT]
2286 ˇcreated
2287 "
2288 ));
2289
2290 cx.dispatch_action(editor::actions::GoToPreviousHunk);
2291
2292 cx.assert_excerpts_with_selections(indoc!(
2293 "
2294 [EXCERPT]
2295 before
2296 really changed
2297 [EXCERPT]
2298 ˇ[FOLDED]
2299 [EXCERPT]
2300 created
2301 "
2302 ));
2303
2304 cx.dispatch_action(editor::actions::GoToPreviousHunk);
2305
2306 cx.assert_excerpts_with_selections(indoc!(
2307 "
2308 [EXCERPT]
2309 ˇbefore
2310 really changed
2311 [EXCERPT]
2312 [FOLDED]
2313 [EXCERPT]
2314 created
2315 "
2316 ));
2317 }
2318
2319 #[gpui::test]
2320 async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
2321 init_test(cx);
2322
2323 let git_contents = indoc! {r#"
2324 #[rustfmt::skip]
2325 fn main() {
2326 let x = 0.0; // this line will be removed
2327 // 1
2328 // 2
2329 // 3
2330 let y = 0.0; // this line will be removed
2331 // 1
2332 // 2
2333 // 3
2334 let arr = [
2335 0.0, // this line will be removed
2336 0.0, // this line will be removed
2337 0.0, // this line will be removed
2338 0.0, // this line will be removed
2339 ];
2340 }
2341 "#};
2342 let buffer_contents = indoc! {"
2343 #[rustfmt::skip]
2344 fn main() {
2345 // 1
2346 // 2
2347 // 3
2348 // 1
2349 // 2
2350 // 3
2351 let arr = [
2352 ];
2353 }
2354 "};
2355
2356 let fs = FakeFs::new(cx.executor());
2357 fs.insert_tree(
2358 path!("/a"),
2359 json!({
2360 ".git": {},
2361 "main.rs": buffer_contents,
2362 }),
2363 )
2364 .await;
2365
2366 fs.set_head_and_index_for_repo(
2367 Path::new(path!("/a/.git")),
2368 &[("main.rs", git_contents.to_owned())],
2369 );
2370
2371 let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2372 let (multi_workspace, cx) =
2373 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2374 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2375
2376 cx.run_until_parked();
2377
2378 cx.focus(&workspace);
2379 cx.update(|window, cx| {
2380 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2381 });
2382
2383 cx.run_until_parked();
2384
2385 let item = workspace.update(cx, |workspace, cx| {
2386 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2387 });
2388 cx.focus(&item);
2389 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2390
2391 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2392
2393 cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2394
2395 cx.dispatch_action(editor::actions::GoToHunk);
2396 cx.dispatch_action(editor::actions::GoToHunk);
2397 cx.dispatch_action(git::Restore);
2398 cx.dispatch_action(editor::actions::MoveToBeginning);
2399
2400 cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2401 }
2402
2403 #[gpui::test]
2404 async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) {
2405 init_test(cx);
2406
2407 let fs = FakeFs::new(cx.executor());
2408 fs.insert_tree(
2409 path!("/project"),
2410 json!({
2411 ".git": {},
2412 "foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n",
2413 }),
2414 )
2415 .await;
2416 fs.set_status_for_repo(
2417 Path::new(path!("/project/.git")),
2418 &[(
2419 "foo",
2420 UnmergedStatus {
2421 first_head: UnmergedStatusCode::Updated,
2422 second_head: UnmergedStatusCode::Updated,
2423 }
2424 .into(),
2425 )],
2426 );
2427 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2428 let (multi_workspace, cx) =
2429 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2430 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2431 let diff = cx.new_window_entity(|window, cx| {
2432 ProjectDiff::new(project.clone(), workspace, window, cx)
2433 });
2434 cx.run_until_parked();
2435
2436 cx.update(|window, cx| {
2437 let editor = diff.read(cx).editor.read(cx).rhs_editor().clone();
2438 let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
2439 assert_eq!(excerpt_ids.len(), 1);
2440 let excerpt_id = excerpt_ids[0];
2441 let buffer = editor
2442 .read(cx)
2443 .buffer()
2444 .read(cx)
2445 .all_buffers()
2446 .into_iter()
2447 .next()
2448 .unwrap();
2449 let buffer_id = buffer.read(cx).remote_id();
2450 let conflict_set = diff
2451 .read(cx)
2452 .editor
2453 .read(cx)
2454 .rhs_editor()
2455 .read(cx)
2456 .addon::<ConflictAddon>()
2457 .unwrap()
2458 .conflict_set(buffer_id)
2459 .unwrap();
2460 assert!(conflict_set.read(cx).has_conflict);
2461 let snapshot = conflict_set.read(cx).snapshot();
2462 assert_eq!(snapshot.conflicts.len(), 1);
2463
2464 let ours_range = snapshot.conflicts[0].ours.clone();
2465
2466 resolve_conflict(
2467 editor.downgrade(),
2468 excerpt_id,
2469 snapshot.conflicts[0].clone(),
2470 vec![ours_range],
2471 window,
2472 cx,
2473 )
2474 })
2475 .await;
2476
2477 let contents = fs.read_file_sync(path!("/project/foo")).unwrap();
2478 let contents = String::from_utf8(contents).unwrap();
2479 assert_eq!(contents, "ours\n");
2480 }
2481
2482 #[gpui::test]
2483 async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) {
2484 init_test(cx);
2485
2486 let fs = FakeFs::new(cx.executor());
2487 fs.insert_tree(
2488 path!("/project"),
2489 json!({
2490 ".git": {},
2491 "foo.txt": "
2492 one
2493 two
2494 three
2495 four
2496 five
2497 six
2498 seven
2499 eight
2500 nine
2501 ten
2502 ELEVEN
2503 twelve
2504 ".unindent()
2505 }),
2506 )
2507 .await;
2508 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2509 let (multi_workspace, cx) =
2510 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2511 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2512 let diff = cx.new_window_entity(|window, cx| {
2513 ProjectDiff::new(project.clone(), workspace, window, cx)
2514 });
2515 cx.run_until_parked();
2516
2517 fs.set_head_and_index_for_repo(
2518 Path::new(path!("/project/.git")),
2519 &[(
2520 "foo.txt",
2521 "
2522 one
2523 two
2524 three
2525 four
2526 five
2527 six
2528 seven
2529 eight
2530 nine
2531 ten
2532 eleven
2533 twelve
2534 "
2535 .unindent(),
2536 )],
2537 );
2538 cx.run_until_parked();
2539
2540 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2541
2542 assert_state_with_diff(
2543 &editor,
2544 cx,
2545 &"
2546 ˇnine
2547 ten
2548 - eleven
2549 + ELEVEN
2550 twelve
2551 "
2552 .unindent(),
2553 );
2554
2555 // The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff.
2556 let buffer = project
2557 .update(cx, |project, cx| {
2558 project.open_local_buffer(path!("/project/foo.txt"), cx)
2559 })
2560 .await
2561 .unwrap();
2562 buffer.update(cx, |buffer, cx| {
2563 buffer.edit_via_marked_text(
2564 &"
2565 one
2566 «TWO»
2567 three
2568 four
2569 five
2570 six
2571 seven
2572 eight
2573 nine
2574 ten
2575 ELEVEN
2576 twelve
2577 "
2578 .unindent(),
2579 None,
2580 cx,
2581 );
2582 });
2583 project
2584 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2585 .await
2586 .unwrap();
2587 cx.run_until_parked();
2588
2589 assert_state_with_diff(
2590 &editor,
2591 cx,
2592 &"
2593 one
2594 - two
2595 + TWO
2596 three
2597 four
2598 five
2599 ˇnine
2600 ten
2601 - eleven
2602 + ELEVEN
2603 twelve
2604 "
2605 .unindent(),
2606 );
2607 }
2608
2609 #[gpui::test]
2610 async fn test_branch_diff(cx: &mut TestAppContext) {
2611 init_test(cx);
2612
2613 let fs = FakeFs::new(cx.executor());
2614 fs.insert_tree(
2615 path!("/project"),
2616 json!({
2617 ".git": {},
2618 "a.txt": "C",
2619 "b.txt": "new",
2620 "c.txt": "in-merge-base-and-work-tree",
2621 "d.txt": "created-in-head",
2622 }),
2623 )
2624 .await;
2625 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2626 let (multi_workspace, cx) =
2627 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2628 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2629 let diff = cx
2630 .update(|window, cx| {
2631 ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
2632 })
2633 .await
2634 .unwrap();
2635 cx.run_until_parked();
2636
2637 fs.set_head_for_repo(
2638 Path::new(path!("/project/.git")),
2639 &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
2640 "sha",
2641 );
2642 // fs.set_index_for_repo(dot_git, index_state);
2643 fs.set_merge_base_content_for_repo(
2644 Path::new(path!("/project/.git")),
2645 &[
2646 ("a.txt", "A".into()),
2647 ("c.txt", "in-merge-base-and-work-tree".into()),
2648 ],
2649 );
2650 cx.run_until_parked();
2651
2652 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2653
2654 assert_state_with_diff(
2655 &editor,
2656 cx,
2657 &"
2658 - A
2659 + ˇC
2660 + new
2661 + created-in-head"
2662 .unindent(),
2663 );
2664
2665 let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
2666 editor.update(cx, |editor, cx| {
2667 editor
2668 .buffer()
2669 .read(cx)
2670 .all_buffers()
2671 .iter()
2672 .map(|buffer| {
2673 (
2674 buffer.read(cx).file().unwrap().path().clone(),
2675 editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
2676 )
2677 })
2678 .collect()
2679 });
2680
2681 assert_eq!(
2682 statuses,
2683 HashMap::from_iter([
2684 (
2685 rel_path("a.txt").into_arc(),
2686 Some(FileStatus::Tracked(TrackedStatus {
2687 index_status: git::status::StatusCode::Modified,
2688 worktree_status: git::status::StatusCode::Modified
2689 }))
2690 ),
2691 (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
2692 (
2693 rel_path("d.txt").into_arc(),
2694 Some(FileStatus::Tracked(TrackedStatus {
2695 index_status: git::status::StatusCode::Added,
2696 worktree_status: git::status::StatusCode::Added
2697 }))
2698 )
2699 ])
2700 );
2701 }
2702
2703 #[gpui::test]
2704 async fn test_update_on_uncommit(cx: &mut TestAppContext) {
2705 init_test(cx);
2706
2707 let fs = FakeFs::new(cx.executor());
2708 fs.insert_tree(
2709 path!("/project"),
2710 json!({
2711 ".git": {},
2712 "README.md": "# My cool project\n".to_owned()
2713 }),
2714 )
2715 .await;
2716 fs.set_head_and_index_for_repo(
2717 Path::new(path!("/project/.git")),
2718 &[("README.md", "# My cool project\n".to_owned())],
2719 );
2720 let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
2721 let worktree_id = project.read_with(cx, |project, cx| {
2722 project.worktrees(cx).next().unwrap().read(cx).id()
2723 });
2724 let (multi_workspace, cx) =
2725 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2726 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2727 cx.run_until_parked();
2728
2729 let _editor = workspace
2730 .update_in(cx, |workspace, window, cx| {
2731 workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
2732 })
2733 .await
2734 .unwrap()
2735 .downcast::<Editor>()
2736 .unwrap();
2737
2738 cx.focus(&workspace);
2739 cx.update(|window, cx| {
2740 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2741 });
2742 cx.run_until_parked();
2743 let item = workspace.update(cx, |workspace, cx| {
2744 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2745 });
2746 cx.focus(&item);
2747 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2748
2749 fs.set_head_and_index_for_repo(
2750 Path::new(path!("/project/.git")),
2751 &[(
2752 "README.md",
2753 "# My cool project\nDetails to come.\n".to_owned(),
2754 )],
2755 );
2756 cx.run_until_parked();
2757
2758 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2759
2760 cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
2761 }
2762
2763 #[gpui::test]
2764 async fn test_deploy_at_respects_worktree_override(cx: &mut TestAppContext) {
2765 init_test(cx);
2766
2767 let fs = FakeFs::new(cx.executor());
2768 fs.insert_tree(
2769 path!("/project_a"),
2770 json!({
2771 ".git": {},
2772 "a.txt": "CHANGED_A\n",
2773 }),
2774 )
2775 .await;
2776 fs.insert_tree(
2777 path!("/project_b"),
2778 json!({
2779 ".git": {},
2780 "b.txt": "CHANGED_B\n",
2781 }),
2782 )
2783 .await;
2784
2785 fs.set_head_and_index_for_repo(
2786 Path::new(path!("/project_a/.git")),
2787 &[("a.txt", "original_a\n".to_string())],
2788 );
2789 fs.set_head_and_index_for_repo(
2790 Path::new(path!("/project_b/.git")),
2791 &[("b.txt", "original_b\n".to_string())],
2792 );
2793
2794 let project = Project::test(
2795 fs.clone(),
2796 [
2797 Path::new(path!("/project_a")),
2798 Path::new(path!("/project_b")),
2799 ],
2800 cx,
2801 )
2802 .await;
2803
2804 let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| {
2805 let mut worktrees: Vec<_> = project.worktrees(cx).collect();
2806 worktrees.sort_by_key(|w| w.read(cx).abs_path());
2807 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
2808 });
2809
2810 let (multi_workspace, cx) =
2811 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2812 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2813 cx.run_until_parked();
2814
2815 // Select project A via the dropdown override and open the diff.
2816 workspace.update(cx, |workspace, cx| {
2817 workspace.set_active_worktree_override(Some(worktree_a_id), cx);
2818 });
2819 cx.focus(&workspace);
2820 cx.update(|window, cx| {
2821 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2822 });
2823 cx.run_until_parked();
2824
2825 let diff_item = workspace.update(cx, |workspace, cx| {
2826 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2827 });
2828 let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
2829 assert_eq!(paths_a.len(), 1);
2830 assert_eq!(*paths_a[0], *"a.txt");
2831
2832 // Switch the override to project B and re-run the diff action.
2833 workspace.update(cx, |workspace, cx| {
2834 workspace.set_active_worktree_override(Some(worktree_b_id), cx);
2835 });
2836 cx.focus(&workspace);
2837 cx.update(|window, cx| {
2838 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2839 });
2840 cx.run_until_parked();
2841
2842 let same_diff_item = workspace.update(cx, |workspace, cx| {
2843 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2844 });
2845 assert_eq!(diff_item.entity_id(), same_diff_item.entity_id());
2846
2847 let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
2848 assert_eq!(paths_b.len(), 1);
2849 assert_eq!(*paths_b[0], *"b.txt");
2850 }
2851}