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 .start_icon(
1596 Icon::new(IconName::ZedAssistant)
1597 .size(IconSize::Small)
1598 .color(Color::Muted),
1599 )
1600 .tooltip(Tooltip::for_action_title_in(
1601 "Send all review comments to the Agent panel",
1602 &SendReviewToAgent,
1603 focus_handle,
1604 ))
1605}
1606
1607pub struct BranchDiffToolbar {
1608 project_diff: Option<WeakEntity<ProjectDiff>>,
1609}
1610
1611impl BranchDiffToolbar {
1612 pub fn new(_cx: &mut Context<Self>) -> Self {
1613 Self { project_diff: None }
1614 }
1615
1616 fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
1617 self.project_diff.as_ref()?.upgrade()
1618 }
1619
1620 fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
1621 if let Some(project_diff) = self.project_diff(cx) {
1622 project_diff.focus_handle(cx).focus(window, cx);
1623 }
1624 let action = action.boxed_clone();
1625 cx.defer(move |cx| {
1626 cx.dispatch_action(action.as_ref());
1627 })
1628 }
1629}
1630
1631impl EventEmitter<ToolbarItemEvent> for BranchDiffToolbar {}
1632
1633impl ToolbarItemView for BranchDiffToolbar {
1634 fn set_active_pane_item(
1635 &mut self,
1636 active_pane_item: Option<&dyn ItemHandle>,
1637 _: &mut Window,
1638 cx: &mut Context<Self>,
1639 ) -> ToolbarItemLocation {
1640 self.project_diff = active_pane_item
1641 .and_then(|item| item.act_as::<ProjectDiff>(cx))
1642 .filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }))
1643 .map(|entity| entity.downgrade());
1644 if self.project_diff.is_some() {
1645 ToolbarItemLocation::PrimaryRight
1646 } else {
1647 ToolbarItemLocation::Hidden
1648 }
1649 }
1650
1651 fn pane_focus_update(
1652 &mut self,
1653 _pane_focused: bool,
1654 _window: &mut Window,
1655 _cx: &mut Context<Self>,
1656 ) {
1657 }
1658}
1659
1660impl Render for BranchDiffToolbar {
1661 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1662 let Some(project_diff) = self.project_diff(cx) else {
1663 return div();
1664 };
1665 let focus_handle = project_diff.focus_handle(cx);
1666 let review_count = project_diff.read(cx).total_review_comment_count();
1667 let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx);
1668
1669 let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty();
1670 let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
1671
1672 let show_review_button = !is_multibuffer_empty && is_ai_enabled;
1673
1674 h_group_xl()
1675 .my_neg_1()
1676 .py_1()
1677 .items_center()
1678 .flex_wrap()
1679 .justify_end()
1680 .gap_2()
1681 .when(!is_multibuffer_empty, |this| {
1682 this.child(DiffStat::new(
1683 "branch-diff-stat",
1684 additions as usize,
1685 deletions as usize,
1686 ))
1687 })
1688 .when(show_review_button, |this| {
1689 let focus_handle = focus_handle.clone();
1690 this.child(Divider::vertical()).child(
1691 Button::new("review-diff", "Review Diff")
1692 .start_icon(
1693 Icon::new(IconName::ZedAssistant)
1694 .size(IconSize::Small)
1695 .color(Color::Muted),
1696 )
1697 .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx))
1698 .tooltip(move |_, cx| {
1699 Tooltip::with_meta_in(
1700 "Review Diff",
1701 Some(&ReviewDiff),
1702 "Send this diff for your last agent to review.",
1703 &focus_handle,
1704 cx,
1705 )
1706 })
1707 .on_click(cx.listener(|this, _, window, cx| {
1708 this.dispatch_action(&ReviewDiff, window, cx);
1709 })),
1710 )
1711 })
1712 .when(review_count > 0, |this| {
1713 this.child(vertical_divider()).child(
1714 render_send_review_to_agent_button(review_count, &focus_handle).on_click(
1715 cx.listener(|this, _, window, cx| {
1716 this.dispatch_action(&SendReviewToAgent, window, cx)
1717 }),
1718 ),
1719 )
1720 })
1721 }
1722}
1723
1724struct BranchDiffAddon {
1725 branch_diff: Entity<branch_diff::BranchDiff>,
1726}
1727
1728impl Addon for BranchDiffAddon {
1729 fn to_any(&self) -> &dyn std::any::Any {
1730 self
1731 }
1732
1733 fn override_status_for_buffer_id(
1734 &self,
1735 buffer_id: language::BufferId,
1736 cx: &App,
1737 ) -> Option<FileStatus> {
1738 self.branch_diff
1739 .read(cx)
1740 .status_for_buffer_id(buffer_id, cx)
1741 }
1742}
1743
1744#[cfg(test)]
1745mod tests {
1746 use collections::HashMap;
1747 use db::indoc;
1748 use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
1749 use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
1750 use gpui::TestAppContext;
1751 use project::FakeFs;
1752 use serde_json::json;
1753 use settings::{DiffViewStyle, SettingsStore};
1754 use std::path::Path;
1755 use unindent::Unindent as _;
1756 use util::{
1757 path,
1758 rel_path::{RelPath, rel_path},
1759 };
1760
1761 use workspace::MultiWorkspace;
1762
1763 use super::*;
1764
1765 #[ctor::ctor]
1766 fn init_logger() {
1767 zlog::init_test();
1768 }
1769
1770 fn init_test(cx: &mut TestAppContext) {
1771 cx.update(|cx| {
1772 let store = SettingsStore::test(cx);
1773 cx.set_global(store);
1774 cx.update_global::<SettingsStore, _>(|store, cx| {
1775 store.update_user_settings(cx, |settings| {
1776 settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
1777 });
1778 });
1779 theme::init(theme::LoadThemes::JustBase, cx);
1780 editor::init(cx);
1781 crate::init(cx);
1782 });
1783 }
1784
1785 #[gpui::test]
1786 async fn test_save_after_restore(cx: &mut TestAppContext) {
1787 init_test(cx);
1788
1789 let fs = FakeFs::new(cx.executor());
1790 fs.insert_tree(
1791 path!("/project"),
1792 json!({
1793 ".git": {},
1794 "foo.txt": "FOO\n",
1795 }),
1796 )
1797 .await;
1798 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1799
1800 fs.set_head_for_repo(
1801 path!("/project/.git").as_ref(),
1802 &[("foo.txt", "foo\n".into())],
1803 "deadbeef",
1804 );
1805 fs.set_index_for_repo(
1806 path!("/project/.git").as_ref(),
1807 &[("foo.txt", "foo\n".into())],
1808 );
1809
1810 let (multi_workspace, cx) =
1811 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1812 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1813 let diff = cx.new_window_entity(|window, cx| {
1814 ProjectDiff::new(project.clone(), workspace, window, cx)
1815 });
1816 cx.run_until_parked();
1817
1818 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
1819 assert_state_with_diff(
1820 &editor,
1821 cx,
1822 &"
1823 - ˇfoo
1824 + FOO
1825 "
1826 .unindent(),
1827 );
1828
1829 editor
1830 .update_in(cx, |editor, window, cx| {
1831 editor.git_restore(&Default::default(), window, cx);
1832 editor.save(SaveOptions::default(), project.clone(), window, cx)
1833 })
1834 .await
1835 .unwrap();
1836 cx.run_until_parked();
1837
1838 assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1839
1840 let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1841 assert_eq!(text, "foo\n");
1842 }
1843
1844 #[gpui::test]
1845 async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1846 init_test(cx);
1847
1848 let fs = FakeFs::new(cx.executor());
1849 fs.insert_tree(
1850 path!("/project"),
1851 json!({
1852 ".git": {},
1853 "bar": "BAR\n",
1854 "foo": "FOO\n",
1855 }),
1856 )
1857 .await;
1858 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1859 let (multi_workspace, cx) =
1860 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1861 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1862 let diff = cx.new_window_entity(|window, cx| {
1863 ProjectDiff::new(project.clone(), workspace, window, cx)
1864 });
1865 cx.run_until_parked();
1866
1867 fs.set_head_and_index_for_repo(
1868 path!("/project/.git").as_ref(),
1869 &[("bar", "bar\n".into()), ("foo", "foo\n".into())],
1870 );
1871 cx.run_until_parked();
1872
1873 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1874 diff.move_to_path(
1875 PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()),
1876 window,
1877 cx,
1878 );
1879 diff.editor.read(cx).rhs_editor().clone()
1880 });
1881 assert_state_with_diff(
1882 &editor,
1883 cx,
1884 &"
1885 - bar
1886 + BAR
1887
1888 - ˇfoo
1889 + FOO
1890 "
1891 .unindent(),
1892 );
1893
1894 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1895 diff.move_to_path(
1896 PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()),
1897 window,
1898 cx,
1899 );
1900 diff.editor.read(cx).rhs_editor().clone()
1901 });
1902 assert_state_with_diff(
1903 &editor,
1904 cx,
1905 &"
1906 - ˇbar
1907 + BAR
1908
1909 - foo
1910 + FOO
1911 "
1912 .unindent(),
1913 );
1914 }
1915
1916 #[gpui::test]
1917 async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1918 init_test(cx);
1919
1920 let fs = FakeFs::new(cx.executor());
1921 fs.insert_tree(
1922 path!("/project"),
1923 json!({
1924 ".git": {},
1925 "foo": "modified\n",
1926 }),
1927 )
1928 .await;
1929 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1930 let (multi_workspace, cx) =
1931 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1932 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1933 fs.set_head_for_repo(
1934 path!("/project/.git").as_ref(),
1935 &[("foo", "original\n".into())],
1936 "deadbeef",
1937 );
1938
1939 let buffer = project
1940 .update(cx, |project, cx| {
1941 project.open_local_buffer(path!("/project/foo"), cx)
1942 })
1943 .await
1944 .unwrap();
1945 let buffer_editor = cx.new_window_entity(|window, cx| {
1946 Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1947 });
1948 let diff = cx.new_window_entity(|window, cx| {
1949 ProjectDiff::new(project.clone(), workspace, window, cx)
1950 });
1951 cx.run_until_parked();
1952
1953 let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
1954
1955 assert_state_with_diff(
1956 &diff_editor,
1957 cx,
1958 &"
1959 - ˇoriginal
1960 + modified
1961 "
1962 .unindent(),
1963 );
1964
1965 let prev_buffer_hunks =
1966 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1967 let snapshot = buffer_editor.snapshot(window, cx);
1968 let snapshot = &snapshot.buffer_snapshot();
1969 let prev_buffer_hunks = buffer_editor
1970 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1971 .collect::<Vec<_>>();
1972 buffer_editor.git_restore(&Default::default(), window, cx);
1973 prev_buffer_hunks
1974 });
1975 assert_eq!(prev_buffer_hunks.len(), 1);
1976 cx.run_until_parked();
1977
1978 let new_buffer_hunks =
1979 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1980 let snapshot = buffer_editor.snapshot(window, cx);
1981 let snapshot = &snapshot.buffer_snapshot();
1982 buffer_editor
1983 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1984 .collect::<Vec<_>>()
1985 });
1986 assert_eq!(new_buffer_hunks.as_slice(), &[]);
1987
1988 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1989 buffer_editor.set_text("different\n", window, cx);
1990 buffer_editor.save(
1991 SaveOptions {
1992 format: false,
1993 autosave: false,
1994 },
1995 project.clone(),
1996 window,
1997 cx,
1998 )
1999 })
2000 .await
2001 .unwrap();
2002
2003 cx.run_until_parked();
2004
2005 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2006 buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
2007 });
2008
2009 assert_state_with_diff(
2010 &buffer_editor,
2011 cx,
2012 &"
2013 - original
2014 + different
2015 ˇ"
2016 .unindent(),
2017 );
2018
2019 assert_state_with_diff(
2020 &diff_editor,
2021 cx,
2022 &"
2023 - ˇoriginal
2024 + different
2025 "
2026 .unindent(),
2027 );
2028 }
2029
2030 use crate::{
2031 conflict_view::resolve_conflict,
2032 project_diff::{self, ProjectDiff},
2033 };
2034
2035 #[gpui::test]
2036 async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
2037 init_test(cx);
2038
2039 let fs = FakeFs::new(cx.executor());
2040 fs.insert_tree(
2041 path!("/a"),
2042 json!({
2043 ".git": {},
2044 "a.txt": "created\n",
2045 "b.txt": "really changed\n",
2046 "c.txt": "unchanged\n"
2047 }),
2048 )
2049 .await;
2050
2051 fs.set_head_and_index_for_repo(
2052 Path::new(path!("/a/.git")),
2053 &[
2054 ("b.txt", "before\n".to_string()),
2055 ("c.txt", "unchanged\n".to_string()),
2056 ("d.txt", "deleted\n".to_string()),
2057 ],
2058 );
2059
2060 let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2061 let (multi_workspace, cx) =
2062 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2063 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2064
2065 cx.run_until_parked();
2066
2067 cx.focus(&workspace);
2068 cx.update(|window, cx| {
2069 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2070 });
2071
2072 cx.run_until_parked();
2073
2074 let item = workspace.update(cx, |workspace, cx| {
2075 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2076 });
2077 cx.focus(&item);
2078 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2079
2080 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2081
2082 cx.assert_excerpts_with_selections(indoc!(
2083 "
2084 [EXCERPT]
2085 before
2086 really changed
2087 [EXCERPT]
2088 [FOLDED]
2089 [EXCERPT]
2090 ˇcreated
2091 "
2092 ));
2093
2094 cx.dispatch_action(editor::actions::GoToPreviousHunk);
2095
2096 cx.assert_excerpts_with_selections(indoc!(
2097 "
2098 [EXCERPT]
2099 before
2100 really changed
2101 [EXCERPT]
2102 ˇ[FOLDED]
2103 [EXCERPT]
2104 created
2105 "
2106 ));
2107
2108 cx.dispatch_action(editor::actions::GoToPreviousHunk);
2109
2110 cx.assert_excerpts_with_selections(indoc!(
2111 "
2112 [EXCERPT]
2113 ˇbefore
2114 really changed
2115 [EXCERPT]
2116 [FOLDED]
2117 [EXCERPT]
2118 created
2119 "
2120 ));
2121 }
2122
2123 #[gpui::test]
2124 async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
2125 init_test(cx);
2126
2127 let git_contents = indoc! {r#"
2128 #[rustfmt::skip]
2129 fn main() {
2130 let x = 0.0; // this line will be removed
2131 // 1
2132 // 2
2133 // 3
2134 let y = 0.0; // this line will be removed
2135 // 1
2136 // 2
2137 // 3
2138 let arr = [
2139 0.0, // this line will be removed
2140 0.0, // this line will be removed
2141 0.0, // this line will be removed
2142 0.0, // this line will be removed
2143 ];
2144 }
2145 "#};
2146 let buffer_contents = indoc! {"
2147 #[rustfmt::skip]
2148 fn main() {
2149 // 1
2150 // 2
2151 // 3
2152 // 1
2153 // 2
2154 // 3
2155 let arr = [
2156 ];
2157 }
2158 "};
2159
2160 let fs = FakeFs::new(cx.executor());
2161 fs.insert_tree(
2162 path!("/a"),
2163 json!({
2164 ".git": {},
2165 "main.rs": buffer_contents,
2166 }),
2167 )
2168 .await;
2169
2170 fs.set_head_and_index_for_repo(
2171 Path::new(path!("/a/.git")),
2172 &[("main.rs", git_contents.to_owned())],
2173 );
2174
2175 let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2176 let (multi_workspace, cx) =
2177 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2178 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2179
2180 cx.run_until_parked();
2181
2182 cx.focus(&workspace);
2183 cx.update(|window, cx| {
2184 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2185 });
2186
2187 cx.run_until_parked();
2188
2189 let item = workspace.update(cx, |workspace, cx| {
2190 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2191 });
2192 cx.focus(&item);
2193 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2194
2195 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2196
2197 cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2198
2199 cx.dispatch_action(editor::actions::GoToHunk);
2200 cx.dispatch_action(editor::actions::GoToHunk);
2201 cx.dispatch_action(git::Restore);
2202 cx.dispatch_action(editor::actions::MoveToBeginning);
2203
2204 cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2205 }
2206
2207 #[gpui::test]
2208 async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) {
2209 init_test(cx);
2210
2211 let fs = FakeFs::new(cx.executor());
2212 fs.insert_tree(
2213 path!("/project"),
2214 json!({
2215 ".git": {},
2216 "foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n",
2217 }),
2218 )
2219 .await;
2220 fs.set_status_for_repo(
2221 Path::new(path!("/project/.git")),
2222 &[(
2223 "foo",
2224 UnmergedStatus {
2225 first_head: UnmergedStatusCode::Updated,
2226 second_head: UnmergedStatusCode::Updated,
2227 }
2228 .into(),
2229 )],
2230 );
2231 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2232 let (multi_workspace, cx) =
2233 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2234 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2235 let diff = cx.new_window_entity(|window, cx| {
2236 ProjectDiff::new(project.clone(), workspace, window, cx)
2237 });
2238 cx.run_until_parked();
2239
2240 cx.update(|window, cx| {
2241 let editor = diff.read(cx).editor.read(cx).rhs_editor().clone();
2242 let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
2243 assert_eq!(excerpt_ids.len(), 1);
2244 let excerpt_id = excerpt_ids[0];
2245 let buffer = editor
2246 .read(cx)
2247 .buffer()
2248 .read(cx)
2249 .all_buffers()
2250 .into_iter()
2251 .next()
2252 .unwrap();
2253 let buffer_id = buffer.read(cx).remote_id();
2254 let conflict_set = diff
2255 .read(cx)
2256 .editor
2257 .read(cx)
2258 .rhs_editor()
2259 .read(cx)
2260 .addon::<ConflictAddon>()
2261 .unwrap()
2262 .conflict_set(buffer_id)
2263 .unwrap();
2264 assert!(conflict_set.read(cx).has_conflict);
2265 let snapshot = conflict_set.read(cx).snapshot();
2266 assert_eq!(snapshot.conflicts.len(), 1);
2267
2268 let ours_range = snapshot.conflicts[0].ours.clone();
2269
2270 resolve_conflict(
2271 editor.downgrade(),
2272 excerpt_id,
2273 snapshot.conflicts[0].clone(),
2274 vec![ours_range],
2275 window,
2276 cx,
2277 )
2278 })
2279 .await;
2280
2281 let contents = fs.read_file_sync(path!("/project/foo")).unwrap();
2282 let contents = String::from_utf8(contents).unwrap();
2283 assert_eq!(contents, "ours\n");
2284 }
2285
2286 #[gpui::test(iterations = 50)]
2287 async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics(
2288 cx: &mut TestAppContext,
2289 ) {
2290 init_test(cx);
2291
2292 cx.update(|cx| {
2293 cx.update_global::<SettingsStore, _>(|store, cx| {
2294 store.update_user_settings(cx, |settings| {
2295 settings.editor.diff_view_style = Some(DiffViewStyle::Split);
2296 });
2297 });
2298 });
2299
2300 let build_conflict_text: fn(usize) -> String = |tag: usize| {
2301 let mut lines = (0..80)
2302 .map(|line_index| format!("line {line_index}"))
2303 .collect::<Vec<_>>();
2304 for offset in [5usize, 20, 37, 61] {
2305 lines[offset] = format!("base-{tag}-line-{offset}");
2306 }
2307 format!("{}\n", lines.join("\n"))
2308 };
2309 let initial_conflict_text = build_conflict_text(0);
2310 let fs = FakeFs::new(cx.executor());
2311 fs.insert_tree(
2312 path!("/project"),
2313 json!({
2314 ".git": {},
2315 "helper.txt": "same\n",
2316 "conflict.txt": initial_conflict_text,
2317 }),
2318 )
2319 .await;
2320 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
2321 state
2322 .refs
2323 .insert("MERGE_HEAD".into(), "conflict-head".into());
2324 })
2325 .unwrap();
2326 fs.set_status_for_repo(
2327 path!("/project/.git").as_ref(),
2328 &[(
2329 "conflict.txt",
2330 FileStatus::Unmerged(UnmergedStatus {
2331 first_head: UnmergedStatusCode::Updated,
2332 second_head: UnmergedStatusCode::Updated,
2333 }),
2334 )],
2335 );
2336 fs.set_merge_base_content_for_repo(
2337 path!("/project/.git").as_ref(),
2338 &[
2339 ("conflict.txt", build_conflict_text(1)),
2340 ("helper.txt", "same\n".to_string()),
2341 ],
2342 );
2343
2344 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2345 let (multi_workspace, cx) =
2346 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2347 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2348 let _project_diff = cx
2349 .update(|window, cx| {
2350 ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
2351 })
2352 .await
2353 .unwrap();
2354 cx.run_until_parked();
2355
2356 let buffer = project
2357 .update(cx, |project, cx| {
2358 project.open_local_buffer(path!("/project/conflict.txt"), cx)
2359 })
2360 .await
2361 .unwrap();
2362 buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx));
2363 assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
2364 cx.run_until_parked();
2365
2366 cx.update(|window, cx| {
2367 let fs = fs.clone();
2368 window
2369 .spawn(cx, async move |cx| {
2370 cx.background_executor().simulate_random_delay().await;
2371 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
2372 state.refs.insert("HEAD".into(), "head-1".into());
2373 state.refs.remove("MERGE_HEAD");
2374 })
2375 .unwrap();
2376 fs.set_status_for_repo(
2377 path!("/project/.git").as_ref(),
2378 &[
2379 (
2380 "conflict.txt",
2381 FileStatus::Tracked(TrackedStatus {
2382 index_status: git::status::StatusCode::Modified,
2383 worktree_status: git::status::StatusCode::Modified,
2384 }),
2385 ),
2386 (
2387 "helper.txt",
2388 FileStatus::Tracked(TrackedStatus {
2389 index_status: git::status::StatusCode::Modified,
2390 worktree_status: git::status::StatusCode::Modified,
2391 }),
2392 ),
2393 ],
2394 );
2395 // FakeFs assigns deterministic OIDs by entry position; flipping order churns
2396 // conflict diff identity without reaching into ProjectDiff internals.
2397 fs.set_merge_base_content_for_repo(
2398 path!("/project/.git").as_ref(),
2399 &[
2400 ("helper.txt", "helper-base\n".to_string()),
2401 ("conflict.txt", build_conflict_text(2)),
2402 ],
2403 );
2404 })
2405 .detach();
2406 });
2407
2408 cx.update(|window, cx| {
2409 let buffer = buffer.clone();
2410 window
2411 .spawn(cx, async move |cx| {
2412 cx.background_executor().simulate_random_delay().await;
2413 for edit_index in 0..10 {
2414 if edit_index > 0 {
2415 cx.background_executor().simulate_random_delay().await;
2416 }
2417 buffer.update(cx, |buffer, cx| {
2418 let len = buffer.len();
2419 if edit_index % 2 == 0 {
2420 buffer.edit(
2421 [(0..0, format!("status-burst-head-{edit_index}\n"))],
2422 None,
2423 cx,
2424 );
2425 } else {
2426 buffer.edit(
2427 [(len..len, format!("status-burst-tail-{edit_index}\n"))],
2428 None,
2429 cx,
2430 );
2431 }
2432 });
2433 }
2434 })
2435 .detach();
2436 });
2437
2438 cx.run_until_parked();
2439 }
2440
2441 #[gpui::test]
2442 async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) {
2443 init_test(cx);
2444
2445 let fs = FakeFs::new(cx.executor());
2446 fs.insert_tree(
2447 path!("/project"),
2448 json!({
2449 ".git": {},
2450 "foo.txt": "
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 ".unindent()
2464 }),
2465 )
2466 .await;
2467 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2468 let (multi_workspace, cx) =
2469 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2470 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2471 let diff = cx.new_window_entity(|window, cx| {
2472 ProjectDiff::new(project.clone(), workspace, window, cx)
2473 });
2474 cx.run_until_parked();
2475
2476 fs.set_head_and_index_for_repo(
2477 Path::new(path!("/project/.git")),
2478 &[(
2479 "foo.txt",
2480 "
2481 one
2482 two
2483 three
2484 four
2485 five
2486 six
2487 seven
2488 eight
2489 nine
2490 ten
2491 eleven
2492 twelve
2493 "
2494 .unindent(),
2495 )],
2496 );
2497 cx.run_until_parked();
2498
2499 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2500
2501 assert_state_with_diff(
2502 &editor,
2503 cx,
2504 &"
2505 ˇnine
2506 ten
2507 - eleven
2508 + ELEVEN
2509 twelve
2510 "
2511 .unindent(),
2512 );
2513
2514 // The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff.
2515 let buffer = project
2516 .update(cx, |project, cx| {
2517 project.open_local_buffer(path!("/project/foo.txt"), cx)
2518 })
2519 .await
2520 .unwrap();
2521 buffer.update(cx, |buffer, cx| {
2522 buffer.edit_via_marked_text(
2523 &"
2524 one
2525 «TWO»
2526 three
2527 four
2528 five
2529 six
2530 seven
2531 eight
2532 nine
2533 ten
2534 ELEVEN
2535 twelve
2536 "
2537 .unindent(),
2538 None,
2539 cx,
2540 );
2541 });
2542 project
2543 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2544 .await
2545 .unwrap();
2546 cx.run_until_parked();
2547
2548 assert_state_with_diff(
2549 &editor,
2550 cx,
2551 &"
2552 one
2553 - two
2554 + TWO
2555 three
2556 four
2557 five
2558 ˇnine
2559 ten
2560 - eleven
2561 + ELEVEN
2562 twelve
2563 "
2564 .unindent(),
2565 );
2566 }
2567
2568 #[gpui::test]
2569 async fn test_branch_diff(cx: &mut TestAppContext) {
2570 init_test(cx);
2571
2572 let fs = FakeFs::new(cx.executor());
2573 fs.insert_tree(
2574 path!("/project"),
2575 json!({
2576 ".git": {},
2577 "a.txt": "C",
2578 "b.txt": "new",
2579 "c.txt": "in-merge-base-and-work-tree",
2580 "d.txt": "created-in-head",
2581 }),
2582 )
2583 .await;
2584 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2585 let (multi_workspace, cx) =
2586 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2587 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2588 let diff = cx
2589 .update(|window, cx| {
2590 ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
2591 })
2592 .await
2593 .unwrap();
2594 cx.run_until_parked();
2595
2596 fs.set_head_for_repo(
2597 Path::new(path!("/project/.git")),
2598 &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
2599 "sha",
2600 );
2601 // fs.set_index_for_repo(dot_git, index_state);
2602 fs.set_merge_base_content_for_repo(
2603 Path::new(path!("/project/.git")),
2604 &[
2605 ("a.txt", "A".into()),
2606 ("c.txt", "in-merge-base-and-work-tree".into()),
2607 ],
2608 );
2609 cx.run_until_parked();
2610
2611 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2612
2613 assert_state_with_diff(
2614 &editor,
2615 cx,
2616 &"
2617 - A
2618 + ˇC
2619 + new
2620 + created-in-head"
2621 .unindent(),
2622 );
2623
2624 let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
2625 editor.update(cx, |editor, cx| {
2626 editor
2627 .buffer()
2628 .read(cx)
2629 .all_buffers()
2630 .iter()
2631 .map(|buffer| {
2632 (
2633 buffer.read(cx).file().unwrap().path().clone(),
2634 editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
2635 )
2636 })
2637 .collect()
2638 });
2639
2640 assert_eq!(
2641 statuses,
2642 HashMap::from_iter([
2643 (
2644 rel_path("a.txt").into_arc(),
2645 Some(FileStatus::Tracked(TrackedStatus {
2646 index_status: git::status::StatusCode::Modified,
2647 worktree_status: git::status::StatusCode::Modified
2648 }))
2649 ),
2650 (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
2651 (
2652 rel_path("d.txt").into_arc(),
2653 Some(FileStatus::Tracked(TrackedStatus {
2654 index_status: git::status::StatusCode::Added,
2655 worktree_status: git::status::StatusCode::Added
2656 }))
2657 )
2658 ])
2659 );
2660 }
2661
2662 #[gpui::test]
2663 async fn test_update_on_uncommit(cx: &mut TestAppContext) {
2664 init_test(cx);
2665
2666 let fs = FakeFs::new(cx.executor());
2667 fs.insert_tree(
2668 path!("/project"),
2669 json!({
2670 ".git": {},
2671 "README.md": "# My cool project\n".to_owned()
2672 }),
2673 )
2674 .await;
2675 fs.set_head_and_index_for_repo(
2676 Path::new(path!("/project/.git")),
2677 &[("README.md", "# My cool project\n".to_owned())],
2678 );
2679 let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
2680 let worktree_id = project.read_with(cx, |project, cx| {
2681 project.worktrees(cx).next().unwrap().read(cx).id()
2682 });
2683 let (multi_workspace, cx) =
2684 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2685 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2686 cx.run_until_parked();
2687
2688 let _editor = workspace
2689 .update_in(cx, |workspace, window, cx| {
2690 workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
2691 })
2692 .await
2693 .unwrap()
2694 .downcast::<Editor>()
2695 .unwrap();
2696
2697 cx.focus(&workspace);
2698 cx.update(|window, cx| {
2699 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2700 });
2701 cx.run_until_parked();
2702 let item = workspace.update(cx, |workspace, cx| {
2703 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2704 });
2705 cx.focus(&item);
2706 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2707
2708 fs.set_head_and_index_for_repo(
2709 Path::new(path!("/project/.git")),
2710 &[(
2711 "README.md",
2712 "# My cool project\nDetails to come.\n".to_owned(),
2713 )],
2714 );
2715 cx.run_until_parked();
2716
2717 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2718
2719 cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
2720 }
2721
2722 #[gpui::test]
2723 async fn test_deploy_at_respects_worktree_override(cx: &mut TestAppContext) {
2724 init_test(cx);
2725
2726 let fs = FakeFs::new(cx.executor());
2727 fs.insert_tree(
2728 path!("/project_a"),
2729 json!({
2730 ".git": {},
2731 "a.txt": "CHANGED_A\n",
2732 }),
2733 )
2734 .await;
2735 fs.insert_tree(
2736 path!("/project_b"),
2737 json!({
2738 ".git": {},
2739 "b.txt": "CHANGED_B\n",
2740 }),
2741 )
2742 .await;
2743
2744 fs.set_head_and_index_for_repo(
2745 Path::new(path!("/project_a/.git")),
2746 &[("a.txt", "original_a\n".to_string())],
2747 );
2748 fs.set_head_and_index_for_repo(
2749 Path::new(path!("/project_b/.git")),
2750 &[("b.txt", "original_b\n".to_string())],
2751 );
2752
2753 let project = Project::test(
2754 fs.clone(),
2755 [
2756 Path::new(path!("/project_a")),
2757 Path::new(path!("/project_b")),
2758 ],
2759 cx,
2760 )
2761 .await;
2762
2763 let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| {
2764 let mut worktrees: Vec<_> = project.worktrees(cx).collect();
2765 worktrees.sort_by_key(|w| w.read(cx).abs_path());
2766 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
2767 });
2768
2769 let (multi_workspace, cx) =
2770 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2771 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2772 cx.run_until_parked();
2773
2774 // Select project A via the dropdown override and open the diff.
2775 workspace.update(cx, |workspace, cx| {
2776 workspace.set_active_worktree_override(Some(worktree_a_id), cx);
2777 });
2778 cx.focus(&workspace);
2779 cx.update(|window, cx| {
2780 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2781 });
2782 cx.run_until_parked();
2783
2784 let diff_item = workspace.update(cx, |workspace, cx| {
2785 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2786 });
2787 let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
2788 assert_eq!(paths_a.len(), 1);
2789 assert_eq!(*paths_a[0], *"a.txt");
2790
2791 // Switch the override to project B and re-run the diff action.
2792 workspace.update(cx, |workspace, cx| {
2793 workspace.set_active_worktree_override(Some(worktree_b_id), cx);
2794 });
2795 cx.focus(&workspace);
2796 cx.update(|window, cx| {
2797 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2798 });
2799 cx.run_until_parked();
2800
2801 let same_diff_item = workspace.update(cx, |workspace, cx| {
2802 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2803 });
2804 assert_eq!(diff_item.entity_id(), same_diff_item.entity_id());
2805
2806 let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
2807 assert_eq!(paths_b.len(), 1);
2808 assert_eq!(*paths_b[0], *"b.txt");
2809 }
2810}