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