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