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