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