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