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