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