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