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