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