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