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