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