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.change_selections(Default::default(), window, cx, |s| {
521 s.select_ranges(vec![
522 multi_buffer::Anchor::min()..multi_buffer::Anchor::min(),
523 ]);
524 });
525 });
526 });
527 }
528
529 fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
530 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
531 self.editor.update(cx, |editor, cx| {
532 editor.rhs_editor().update(cx, |editor, cx| {
533 editor.change_selections(
534 SelectionEffects::scroll(Autoscroll::focused()),
535 window,
536 cx,
537 |s| {
538 s.select_ranges([position..position]);
539 },
540 )
541 })
542 });
543 } else {
544 self.pending_scroll = Some(path_key);
545 }
546 }
547
548 pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
549 let snapshot = self.multibuffer.read(cx).snapshot(cx);
550 let mut total_additions = 0u32;
551 let mut total_deletions = 0u32;
552
553 let mut seen_buffers = HashSet::default();
554 for (_, buffer, _) in snapshot.excerpts() {
555 let buffer_id = buffer.remote_id();
556 if !seen_buffers.insert(buffer_id) {
557 continue;
558 }
559
560 let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else {
561 continue;
562 };
563
564 let base_text = diff.base_text();
565
566 for hunk in diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer) {
567 let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
568 total_additions += added_rows;
569
570 let base_start = base_text
571 .offset_to_point(hunk.diff_base_byte_range.start)
572 .row;
573 let base_end = base_text.offset_to_point(hunk.diff_base_byte_range.end).row;
574 let deleted_rows = base_end.saturating_sub(base_start);
575
576 total_deletions += deleted_rows;
577 }
578 }
579
580 (total_additions, total_deletions)
581 }
582
583 /// Returns the total count of review comments across all hunks/files.
584 pub fn total_review_comment_count(&self) -> usize {
585 self.review_comment_count
586 }
587
588 /// Returns a reference to the splittable editor.
589 pub fn editor(&self) -> &Entity<SplittableEditor> {
590 &self.editor
591 }
592
593 fn button_states(&self, cx: &App) -> ButtonStates {
594 let editor = self.editor.read(cx).rhs_editor().read(cx);
595 let snapshot = self.multibuffer.read(cx).snapshot(cx);
596 let prev_next = snapshot.diff_hunks().nth(1).is_some();
597 let mut selection = true;
598
599 let mut ranges = editor
600 .selections
601 .disjoint_anchor_ranges()
602 .collect::<Vec<_>>();
603 if !ranges.iter().any(|range| range.start != range.end) {
604 selection = false;
605 if let Some((excerpt_id, _, range)) = self
606 .editor
607 .read(cx)
608 .rhs_editor()
609 .read(cx)
610 .active_excerpt(cx)
611 {
612 ranges = vec![multi_buffer::Anchor::range_in_buffer(excerpt_id, range)];
613 } else {
614 ranges = Vec::default();
615 }
616 }
617 let mut has_staged_hunks = false;
618 let mut has_unstaged_hunks = false;
619 for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
620 match hunk.status.secondary {
621 DiffHunkSecondaryStatus::HasSecondaryHunk
622 | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
623 has_unstaged_hunks = true;
624 }
625 DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
626 has_staged_hunks = true;
627 has_unstaged_hunks = true;
628 }
629 DiffHunkSecondaryStatus::NoSecondaryHunk
630 | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
631 has_staged_hunks = true;
632 }
633 }
634 }
635 let mut stage_all = false;
636 let mut unstage_all = false;
637 self.workspace
638 .read_with(cx, |workspace, cx| {
639 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
640 let git_panel = git_panel.read(cx);
641 stage_all = git_panel.can_stage_all();
642 unstage_all = git_panel.can_unstage_all();
643 }
644 })
645 .ok();
646
647 ButtonStates {
648 stage: has_unstaged_hunks,
649 unstage: has_staged_hunks,
650 prev_next,
651 selection,
652 stage_all,
653 unstage_all,
654 }
655 }
656
657 fn handle_editor_event(
658 &mut self,
659 editor: &Entity<SplittableEditor>,
660 event: &EditorEvent,
661 window: &mut Window,
662 cx: &mut Context<Self>,
663 ) {
664 match event {
665 EditorEvent::SelectionsChanged { local: true } => {
666 let Some(project_path) = self.active_path(cx) else {
667 return;
668 };
669 self.workspace
670 .update(cx, |workspace, cx| {
671 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
672 git_panel.update(cx, |git_panel, cx| {
673 git_panel.select_entry_by_path(project_path, window, cx)
674 })
675 }
676 })
677 .ok();
678 }
679 EditorEvent::Saved => {
680 self._task = cx.spawn_in(window, async move |this, cx| {
681 Self::refresh(this, RefreshReason::EditorSaved, cx).await
682 });
683 }
684 _ => {}
685 }
686 if editor.focus_handle(cx).contains_focused(window, cx)
687 && self.multibuffer.read(cx).is_empty()
688 {
689 self.focus_handle.focus(window, cx)
690 }
691 }
692
693 #[instrument(skip_all)]
694 fn register_buffer(
695 &mut self,
696 path_key: PathKey,
697 file_status: FileStatus,
698 buffer: Entity<Buffer>,
699 diff: Entity<BufferDiff>,
700 window: &mut Window,
701 cx: &mut Context<Self>,
702 ) -> Option<BufferId> {
703 let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
704 this._task = window.spawn(cx, {
705 let this = cx.weak_entity();
706 async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await
707 })
708 });
709 self.buffer_diff_subscriptions
710 .insert(path_key.path.clone(), (diff.clone(), subscription));
711
712 // TODO(split-diff) we shouldn't have a conflict addon when split
713 let conflict_addon = self
714 .editor
715 .read(cx)
716 .rhs_editor()
717 .read(cx)
718 .addon::<ConflictAddon>()
719 .expect("project diff editor should have a conflict addon");
720
721 let snapshot = buffer.read(cx).snapshot();
722 let diff_snapshot = diff.read(cx).snapshot(cx);
723
724 let excerpt_ranges = {
725 let diff_hunk_ranges = diff_snapshot
726 .hunks_intersecting_range(
727 Anchor::min_max_range_for_buffer(snapshot.remote_id()),
728 &snapshot,
729 )
730 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
731 let conflicts = conflict_addon
732 .conflict_set(snapshot.remote_id())
733 .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts)
734 .unwrap_or_default();
735 let mut conflicts = conflicts
736 .iter()
737 .map(|conflict| conflict.range.to_point(&snapshot))
738 .peekable();
739
740 if conflicts.peek().is_some() {
741 conflicts.collect::<Vec<_>>()
742 } else {
743 diff_hunk_ranges.collect()
744 }
745 };
746
747 let mut needs_fold = None;
748
749 let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
750 let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
751 let (_, is_newly_added) = editor.set_excerpts_for_path(
752 path_key.clone(),
753 buffer,
754 excerpt_ranges,
755 multibuffer_context_lines(cx),
756 diff,
757 cx,
758 );
759 (was_empty, is_newly_added)
760 });
761
762 self.editor.update(cx, |editor, cx| {
763 editor.rhs_editor().update(cx, |editor, cx| {
764 if was_empty {
765 editor.change_selections(
766 SelectionEffects::no_scroll(),
767 window,
768 cx,
769 |selections| {
770 selections.select_ranges([
771 multi_buffer::Anchor::min()..multi_buffer::Anchor::min()
772 ])
773 },
774 );
775 }
776 if is_excerpt_newly_added
777 && (file_status.is_deleted()
778 || (file_status.is_untracked()
779 && GitPanelSettings::get_global(cx).collapse_untracked_diff))
780 {
781 needs_fold = Some(snapshot.text.remote_id());
782 }
783 })
784 });
785
786 if self.multibuffer.read(cx).is_empty()
787 && self
788 .editor
789 .read(cx)
790 .focus_handle(cx)
791 .contains_focused(window, cx)
792 {
793 self.focus_handle.focus(window, cx);
794 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
795 self.editor.update(cx, |editor, cx| {
796 editor.focus_handle(cx).focus(window, cx);
797 });
798 }
799 if self.pending_scroll.as_ref() == Some(&path_key) {
800 self.move_to_path(path_key, window, cx);
801 }
802
803 needs_fold
804 }
805
806 #[instrument(skip_all)]
807 pub async fn refresh(
808 this: WeakEntity<Self>,
809 reason: RefreshReason,
810 cx: &mut AsyncWindowContext,
811 ) -> Result<()> {
812 let mut path_keys = Vec::new();
813 let buffers_to_load = this.update(cx, |this, cx| {
814 let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
815 let load_buffers = branch_diff.load_buffers(cx);
816 (branch_diff.repo().cloned(), load_buffers)
817 });
818 let mut previous_paths = this
819 .multibuffer
820 .read(cx)
821 .paths()
822 .cloned()
823 .collect::<HashSet<_>>();
824
825 if let Some(repo) = repo {
826 let repo = repo.read(cx);
827
828 path_keys = Vec::with_capacity(buffers_to_load.len());
829 for entry in buffers_to_load.iter() {
830 let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx);
831 let path_key =
832 PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
833 previous_paths.remove(&path_key);
834 path_keys.push(path_key)
835 }
836 }
837
838 this.editor.update(cx, |editor, cx| {
839 for path in previous_paths {
840 if let Some(buffer) = this.multibuffer.read(cx).buffer_for_path(&path, cx) {
841 let skip = match reason {
842 RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
843 buffer.read(cx).is_dirty()
844 }
845 RefreshReason::StatusesChanged => false,
846 };
847 if skip {
848 continue;
849 }
850 }
851
852 this.buffer_diff_subscriptions.remove(&path.path);
853 editor.remove_excerpts_for_path(path, cx);
854 }
855 });
856 buffers_to_load
857 })?;
858
859 let mut buffers_to_fold = Vec::new();
860
861 for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) {
862 if let Some((buffer, diff)) = entry.load.await.log_err() {
863 // We might be lagging behind enough that all future entry.load futures are no longer pending.
864 // If that is the case, this task will never yield, starving the foreground thread of execution time.
865 yield_now().await;
866 cx.update(|window, cx| {
867 this.update(cx, |this, cx| {
868 let multibuffer = this.multibuffer.read(cx);
869 let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some()
870 && multibuffer
871 .diff_for(buffer.read(cx).remote_id())
872 .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id())
873 && match reason {
874 RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
875 buffer.read(cx).is_dirty()
876 }
877 RefreshReason::StatusesChanged => false,
878 };
879 if !skip {
880 if let Some(buffer_id) = this.register_buffer(
881 path_key,
882 entry.file_status,
883 buffer,
884 diff,
885 window,
886 cx,
887 ) {
888 buffers_to_fold.push(buffer_id);
889 }
890 }
891 })
892 .ok();
893 })?;
894 }
895 }
896 this.update(cx, |this, cx| {
897 if !buffers_to_fold.is_empty() {
898 this.editor.update(cx, |editor, cx| {
899 editor
900 .rhs_editor()
901 .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
902 });
903 }
904 this.pending_scroll.take();
905 cx.notify();
906 })?;
907
908 Ok(())
909 }
910
911 #[cfg(any(test, feature = "test-support"))]
912 pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
913 self.multibuffer
914 .read(cx)
915 .paths()
916 .map(|key| key.path.clone())
917 .collect()
918 }
919}
920
921fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
922 let settings = GitPanelSettings::get_global(cx);
923
924 if settings.sort_by_path && !settings.tree_view {
925 TRACKED_SORT_PREFIX
926 } else if repo.had_conflict_on_last_merge_head_change(repo_path) {
927 CONFLICT_SORT_PREFIX
928 } else if status.is_created() {
929 NEW_SORT_PREFIX
930 } else {
931 TRACKED_SORT_PREFIX
932 }
933}
934
935impl EventEmitter<EditorEvent> for ProjectDiff {}
936
937impl Focusable for ProjectDiff {
938 fn focus_handle(&self, cx: &App) -> FocusHandle {
939 if self.multibuffer.read(cx).is_empty() {
940 self.focus_handle.clone()
941 } else {
942 self.editor.focus_handle(cx)
943 }
944 }
945}
946
947impl Item for ProjectDiff {
948 type Event = EditorEvent;
949
950 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
951 Some(Icon::new(IconName::GitBranch).color(Color::Muted))
952 }
953
954 fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
955 Editor::to_item_events(event, f)
956 }
957
958 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
959 self.editor.update(cx, |editor, cx| {
960 editor.rhs_editor().update(cx, |primary_editor, cx| {
961 primary_editor.deactivated(window, cx);
962 })
963 });
964 }
965
966 fn navigate(
967 &mut self,
968 data: Arc<dyn Any + Send>,
969 window: &mut Window,
970 cx: &mut Context<Self>,
971 ) -> bool {
972 self.editor.update(cx, |editor, cx| {
973 editor.rhs_editor().update(cx, |primary_editor, cx| {
974 primary_editor.navigate(data, window, cx)
975 })
976 })
977 }
978
979 fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
980 match self.diff_base(cx) {
981 DiffBase::Head => Some("Project Diff".into()),
982 DiffBase::Merge { .. } => Some("Branch Diff".into()),
983 }
984 }
985
986 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
987 Label::new(self.tab_content_text(0, cx))
988 .color(if params.selected {
989 Color::Default
990 } else {
991 Color::Muted
992 })
993 .into_any_element()
994 }
995
996 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
997 match self.branch_diff.read(cx).diff_base() {
998 DiffBase::Head => "Uncommitted Changes".into(),
999 DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
1000 }
1001 }
1002
1003 fn telemetry_event_text(&self) -> Option<&'static str> {
1004 Some("Project Diff Opened")
1005 }
1006
1007 fn as_searchable(&self, _: &Entity<Self>, _cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
1008 Some(Box::new(self.editor.clone()))
1009 }
1010
1011 fn for_each_project_item(
1012 &self,
1013 cx: &App,
1014 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
1015 ) {
1016 self.editor
1017 .read(cx)
1018 .rhs_editor()
1019 .read(cx)
1020 .for_each_project_item(cx, f)
1021 }
1022
1023 fn set_nav_history(
1024 &mut self,
1025 nav_history: ItemNavHistory,
1026 _: &mut Window,
1027 cx: &mut Context<Self>,
1028 ) {
1029 self.editor.update(cx, |editor, cx| {
1030 editor.rhs_editor().update(cx, |primary_editor, _| {
1031 primary_editor.set_nav_history(Some(nav_history));
1032 })
1033 });
1034 }
1035
1036 fn can_split(&self) -> bool {
1037 true
1038 }
1039
1040 fn clone_on_split(
1041 &self,
1042 _workspace_id: Option<workspace::WorkspaceId>,
1043 window: &mut Window,
1044 cx: &mut Context<Self>,
1045 ) -> Task<Option<Entity<Self>>>
1046 where
1047 Self: Sized,
1048 {
1049 let Some(workspace) = self.workspace.upgrade() else {
1050 return Task::ready(None);
1051 };
1052 Task::ready(Some(cx.new(|cx| {
1053 ProjectDiff::new(self.project.clone(), workspace, window, cx)
1054 })))
1055 }
1056
1057 fn is_dirty(&self, cx: &App) -> bool {
1058 self.multibuffer.read(cx).is_dirty(cx)
1059 }
1060
1061 fn has_conflict(&self, cx: &App) -> bool {
1062 self.multibuffer.read(cx).has_conflict(cx)
1063 }
1064
1065 fn can_save(&self, _: &App) -> bool {
1066 true
1067 }
1068
1069 fn save(
1070 &mut self,
1071 options: SaveOptions,
1072 project: Entity<Project>,
1073 window: &mut Window,
1074 cx: &mut Context<Self>,
1075 ) -> Task<Result<()>> {
1076 self.editor.update(cx, |editor, cx| {
1077 editor.rhs_editor().update(cx, |primary_editor, cx| {
1078 primary_editor.save(options, project, window, cx)
1079 })
1080 })
1081 }
1082
1083 fn save_as(
1084 &mut self,
1085 _: Entity<Project>,
1086 _: ProjectPath,
1087 _window: &mut Window,
1088 _: &mut Context<Self>,
1089 ) -> Task<Result<()>> {
1090 unreachable!()
1091 }
1092
1093 fn reload(
1094 &mut self,
1095 project: Entity<Project>,
1096 window: &mut Window,
1097 cx: &mut Context<Self>,
1098 ) -> Task<Result<()>> {
1099 self.editor.update(cx, |editor, cx| {
1100 editor.rhs_editor().update(cx, |primary_editor, cx| {
1101 primary_editor.reload(project, window, cx)
1102 })
1103 })
1104 }
1105
1106 fn act_as_type<'a>(
1107 &'a self,
1108 type_id: TypeId,
1109 self_handle: &'a Entity<Self>,
1110 cx: &'a App,
1111 ) -> Option<gpui::AnyEntity> {
1112 if type_id == TypeId::of::<Self>() {
1113 Some(self_handle.clone().into())
1114 } else if type_id == TypeId::of::<Editor>() {
1115 Some(self.editor.read(cx).rhs_editor().clone().into())
1116 } else if type_id == TypeId::of::<SplittableEditor>() {
1117 Some(self.editor.clone().into())
1118 } else {
1119 None
1120 }
1121 }
1122
1123 fn added_to_workspace(
1124 &mut self,
1125 workspace: &mut Workspace,
1126 window: &mut Window,
1127 cx: &mut Context<Self>,
1128 ) {
1129 self.editor.update(cx, |editor, cx| {
1130 editor.added_to_workspace(workspace, window, cx)
1131 });
1132 }
1133}
1134
1135impl Render for ProjectDiff {
1136 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1137 let is_empty = self.multibuffer.read(cx).is_empty();
1138 let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. });
1139
1140 div()
1141 .track_focus(&self.focus_handle)
1142 .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
1143 .when(is_branch_diff_view, |this| {
1144 this.on_action(cx.listener(Self::review_diff))
1145 })
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}