1use crate::{
2 conflict_view::ConflictAddon,
3 git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
4 git_panel_settings::GitPanelSettings,
5};
6use agent_settings::AgentSettings;
7use anyhow::{Context as _, Result, anyhow};
8use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
9use collections::HashMap;
10use editor::{
11 Addon, Editor, EditorEvent, EditorSettings, SelectionEffects, SplittableEditor,
12 actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent},
13 multibuffer_context_lines,
14 scroll::Autoscroll,
15};
16use git::repository::DiffType;
17
18use git::{
19 Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, repository::RepoPath,
20 status::FileStatus,
21};
22use gpui::{
23 Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
24 FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions,
25};
26use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt};
27use multi_buffer::{MultiBuffer, PathKey};
28use project::{
29 Project, ProjectPath,
30 git_store::{
31 Repository,
32 branch_diff::{self, BranchDiffEvent, DiffBase},
33 },
34};
35use settings::{Settings, SettingsStore};
36use smol::future::yield_now;
37use std::any::{Any, TypeId};
38use std::sync::Arc;
39use theme::ActiveTheme;
40use ui::{DiffStat, Divider, KeyBinding, Tooltip, prelude::*, vertical_divider};
41use util::{ResultExt as _, rel_path::RelPath};
42use workspace::{
43 CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
44 ToolbarItemView, Workspace,
45 item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
46 notifications::NotifyTaskExt,
47 searchable::SearchableItemHandle,
48};
49use zed_actions::agent::ReviewBranchDiff;
50use ztracing::instrument;
51
52actions!(
53 git,
54 [
55 /// Shows the diff between the working directory and the index.
56 Diff,
57 /// Adds files to the git staging area.
58 Add,
59 /// Shows the diff between the working directory and your default
60 /// branch (typically main or master).
61 BranchDiff,
62 /// Opens a new agent thread with the branch diff for review.
63 ReviewDiff,
64 LeaderAndFollower,
65 ]
66);
67
68pub struct ProjectDiff {
69 project: Entity<Project>,
70 multibuffer: Entity<MultiBuffer>,
71 branch_diff: Entity<branch_diff::BranchDiff>,
72 editor: Entity<SplittableEditor>,
73 buffer_diff_subscriptions: HashMap<Arc<RelPath>, (Entity<BufferDiff>, Subscription)>,
74 workspace: WeakEntity<Workspace>,
75 focus_handle: FocusHandle,
76 pending_scroll: Option<PathKey>,
77 review_comment_count: usize,
78 _task: Task<Result<()>>,
79 _subscription: Subscription,
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum RefreshReason {
84 DiffChanged,
85 StatusesChanged,
86 EditorSaved,
87}
88
89const CONFLICT_SORT_PREFIX: u64 = 1;
90const TRACKED_SORT_PREFIX: u64 = 2;
91const NEW_SORT_PREFIX: u64 = 3;
92
93impl ProjectDiff {
94 pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
95 workspace.register_action(Self::deploy);
96 workspace.register_action(Self::deploy_branch_diff);
97 workspace.register_action(|workspace, _: &Add, window, cx| {
98 Self::deploy(workspace, &Diff, window, cx);
99 });
100 workspace::register_serializable_item::<ProjectDiff>(cx);
101 }
102
103 fn deploy(
104 workspace: &mut Workspace,
105 _: &Diff,
106 window: &mut Window,
107 cx: &mut Context<Workspace>,
108 ) {
109 Self::deploy_at(workspace, None, window, cx)
110 }
111
112 fn deploy_branch_diff(
113 workspace: &mut Workspace,
114 _: &BranchDiff,
115 window: &mut Window,
116 cx: &mut Context<Workspace>,
117 ) {
118 telemetry::event!("Git Branch Diff Opened");
119 let project = workspace.project().clone();
120
121 let existing = workspace
122 .items_of_type::<Self>(cx)
123 .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }));
124 if let Some(existing) = existing {
125 workspace.activate_item(&existing, true, true, window, cx);
126 return;
127 }
128 let workspace = cx.entity();
129 let workspace_weak = workspace.downgrade();
130 window
131 .spawn(cx, async move |cx| {
132 let this = cx
133 .update(|window, cx| {
134 Self::new_with_default_branch(project, workspace.clone(), window, cx)
135 })?
136 .await?;
137 workspace
138 .update_in(cx, |workspace, window, cx| {
139 workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
140 })
141 .ok();
142 anyhow::Ok(())
143 })
144 .detach_and_notify_err(workspace_weak, window, cx);
145 }
146
147 fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context<Self>) {
148 let diff_base = self.diff_base(cx).clone();
149 let DiffBase::Merge { base_ref } = diff_base else {
150 return;
151 };
152
153 let Some(repo) = self.branch_diff.read(cx).repo().cloned() else {
154 return;
155 };
156
157 let diff_receiver = repo.update(cx, |repo, cx| {
158 repo.diff(
159 DiffType::MergeBase {
160 base_ref: base_ref.clone(),
161 },
162 cx,
163 )
164 });
165
166 let workspace = self.workspace.clone();
167
168 window
169 .spawn(cx, {
170 let workspace = workspace.clone();
171 async move |cx| {
172 let diff_text = diff_receiver.await??;
173
174 if let Some(workspace) = workspace.upgrade() {
175 workspace.update_in(cx, |_workspace, window, cx| {
176 window.dispatch_action(
177 ReviewBranchDiff {
178 diff_text: diff_text.into(),
179 base_ref: base_ref.to_string().into(),
180 }
181 .boxed_clone(),
182 cx,
183 );
184 })?;
185 }
186
187 anyhow::Ok(())
188 }
189 })
190 .detach_and_notify_err(workspace, window, cx);
191 }
192
193 pub fn deploy_at(
194 workspace: &mut Workspace,
195 entry: Option<GitStatusEntry>,
196 window: &mut Window,
197 cx: &mut Context<Workspace>,
198 ) {
199 telemetry::event!(
200 "Git Diff Opened",
201 source = if entry.is_some() {
202 "Git Panel"
203 } else {
204 "Action"
205 }
206 );
207 let intended_repo = workspace.project().read(cx).active_repository(cx);
208
209 let existing = workspace
210 .items_of_type::<Self>(cx)
211 .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
212 let project_diff = if let Some(existing) = existing {
213 existing.update(cx, |project_diff, cx| {
214 project_diff.move_to_beginning(window, cx);
215 });
216
217 workspace.activate_item(&existing, true, true, window, cx);
218 existing
219 } else {
220 let workspace_handle = cx.entity();
221 let project_diff =
222 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
223 workspace.add_item_to_active_pane(
224 Box::new(project_diff.clone()),
225 None,
226 true,
227 window,
228 cx,
229 );
230 project_diff
231 };
232
233 if let Some(intended) = &intended_repo {
234 let needs_switch = project_diff
235 .read(cx)
236 .branch_diff
237 .read(cx)
238 .repo()
239 .map_or(true, |current| current.read(cx).id != intended.read(cx).id);
240 if needs_switch {
241 project_diff.update(cx, |project_diff, cx| {
242 project_diff.branch_diff.update(cx, |branch_diff, cx| {
243 branch_diff.set_repo(Some(intended.clone()), cx);
244 });
245 });
246 }
247 }
248
249 if let Some(entry) = entry {
250 project_diff.update(cx, |project_diff, cx| {
251 project_diff.move_to_entry(entry, window, cx);
252 })
253 }
254 }
255
256 pub fn deploy_at_project_path(
257 workspace: &mut Workspace,
258 project_path: ProjectPath,
259 window: &mut Window,
260 cx: &mut Context<Workspace>,
261 ) {
262 telemetry::event!("Git Diff Opened", source = "Agent Panel");
263 let existing = workspace
264 .items_of_type::<Self>(cx)
265 .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
266 let project_diff = if let Some(existing) = existing {
267 workspace.activate_item(&existing, true, true, window, cx);
268 existing
269 } else {
270 let workspace_handle = cx.entity();
271 let project_diff =
272 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
273 workspace.add_item_to_active_pane(
274 Box::new(project_diff.clone()),
275 None,
276 true,
277 window,
278 cx,
279 );
280 project_diff
281 };
282 project_diff.update(cx, |project_diff, cx| {
283 project_diff.move_to_project_path(&project_path, window, cx);
284 });
285 }
286
287 pub fn autoscroll(&self, cx: &mut Context<Self>) {
288 self.editor.update(cx, |editor, cx| {
289 editor.rhs_editor().update(cx, |editor, cx| {
290 editor.request_autoscroll(Autoscroll::fit(), cx);
291 })
292 })
293 }
294
295 fn new_with_default_branch(
296 project: Entity<Project>,
297 workspace: Entity<Workspace>,
298 window: &mut Window,
299 cx: &mut App,
300 ) -> Task<Result<Entity<Self>>> {
301 let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
302 return Task::ready(Err(anyhow!("No active repository")));
303 };
304 let main_branch = repo.update(cx, |repo, _| repo.default_branch(true));
305 window.spawn(cx, async move |cx| {
306 let main_branch = main_branch
307 .await??
308 .context("Could not determine default branch")?;
309
310 let branch_diff = cx.new_window_entity(|window, cx| {
311 branch_diff::BranchDiff::new(
312 DiffBase::Merge {
313 base_ref: main_branch,
314 },
315 project.clone(),
316 window,
317 cx,
318 )
319 })?;
320 cx.new_window_entity(|window, cx| {
321 Self::new_impl(branch_diff, project, workspace, window, cx)
322 })
323 })
324 }
325
326 fn new(
327 project: Entity<Project>,
328 workspace: Entity<Workspace>,
329 window: &mut Window,
330 cx: &mut Context<Self>,
331 ) -> Self {
332 let branch_diff =
333 cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx));
334 Self::new_impl(branch_diff, project, workspace, window, cx)
335 }
336
337 fn new_impl(
338 branch_diff: Entity<branch_diff::BranchDiff>,
339 project: Entity<Project>,
340 workspace: Entity<Workspace>,
341 window: &mut Window,
342 cx: &mut Context<Self>,
343 ) -> Self {
344 let focus_handle = cx.focus_handle();
345 let multibuffer = cx.new(|cx| {
346 let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
347 multibuffer.set_all_diff_hunks_expanded(cx);
348 multibuffer
349 });
350
351 let editor = cx.new(|cx| {
352 let diff_display_editor = SplittableEditor::new(
353 EditorSettings::get_global(cx).diff_view_style,
354 multibuffer.clone(),
355 project.clone(),
356 workspace.clone(),
357 window,
358 cx,
359 );
360 match branch_diff.read(cx).diff_base() {
361 DiffBase::Head => {}
362 DiffBase::Merge { .. } => diff_display_editor.set_render_diff_hunk_controls(
363 Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
364 cx,
365 ),
366 }
367 diff_display_editor.rhs_editor().update(cx, |editor, cx| {
368 editor.disable_diagnostics(cx);
369 editor.set_show_diff_review_button(true, cx);
370
371 match branch_diff.read(cx).diff_base() {
372 DiffBase::Head => {
373 editor.register_addon(GitPanelAddon {
374 workspace: workspace.downgrade(),
375 });
376 }
377 DiffBase::Merge { .. } => {
378 editor.register_addon(BranchDiffAddon {
379 branch_diff: branch_diff.clone(),
380 });
381 }
382 }
383 });
384 diff_display_editor
385 });
386 let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event);
387
388 let primary_editor = editor.read(cx).rhs_editor().clone();
389 let review_comment_subscription =
390 cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| {
391 if let EditorEvent::ReviewCommentsChanged { total_count } = event {
392 this.review_comment_count = *total_count;
393 cx.notify();
394 }
395 });
396
397 let branch_diff_subscription = cx.subscribe_in(
398 &branch_diff,
399 window,
400 move |this, _git_store, event, window, cx| match event {
401 BranchDiffEvent::FileListChanged => {
402 this._task = window.spawn(cx, {
403 let this = cx.weak_entity();
404 async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
405 })
406 }
407 },
408 );
409
410 let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
411 let mut was_collapse_untracked_diff =
412 GitPanelSettings::get_global(cx).collapse_untracked_diff;
413 cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
414 let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
415 let is_collapse_untracked_diff =
416 GitPanelSettings::get_global(cx).collapse_untracked_diff;
417 if is_sort_by_path != was_sort_by_path
418 || is_collapse_untracked_diff != was_collapse_untracked_diff
419 {
420 this._task = {
421 window.spawn(cx, {
422 let this = cx.weak_entity();
423 async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
424 })
425 }
426 }
427 was_sort_by_path = is_sort_by_path;
428 was_collapse_untracked_diff = is_collapse_untracked_diff;
429 })
430 .detach();
431
432 let task = window.spawn(cx, {
433 let this = cx.weak_entity();
434 async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
435 });
436
437 Self {
438 project,
439 workspace: workspace.downgrade(),
440 branch_diff,
441 focus_handle,
442 editor,
443 multibuffer,
444 buffer_diff_subscriptions: Default::default(),
445 pending_scroll: None,
446 review_comment_count: 0,
447 _task: task,
448 _subscription: Subscription::join(
449 branch_diff_subscription,
450 Subscription::join(editor_subscription, review_comment_subscription),
451 ),
452 }
453 }
454
455 pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
456 self.branch_diff.read(cx).diff_base()
457 }
458
459 pub fn move_to_entry(
460 &mut self,
461 entry: GitStatusEntry,
462 window: &mut Window,
463 cx: &mut Context<Self>,
464 ) {
465 let Some(git_repo) = self.branch_diff.read(cx).repo() else {
466 return;
467 };
468 let repo = git_repo.read(cx);
469 let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx);
470 let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
471
472 self.move_to_path(path_key, window, cx)
473 }
474
475 pub fn move_to_project_path(
476 &mut self,
477 project_path: &ProjectPath,
478 window: &mut Window,
479 cx: &mut Context<Self>,
480 ) {
481 let Some(git_repo) = self.branch_diff.read(cx).repo() else {
482 return;
483 };
484 let Some(repo_path) = git_repo
485 .read(cx)
486 .project_path_to_repo_path(project_path, cx)
487 else {
488 return;
489 };
490 let status = git_repo
491 .read(cx)
492 .status_for_path(&repo_path)
493 .map(|entry| entry.status)
494 .unwrap_or(FileStatus::Untracked);
495 let sort_prefix = sort_prefix(&git_repo.read(cx), &repo_path, status, cx);
496 let path_key = PathKey::with_sort_prefix(sort_prefix, repo_path.as_ref().clone());
497 self.move_to_path(path_key, window, cx)
498 }
499
500 pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
501 let editor = self.editor.read(cx).focused_editor().read(cx);
502 let multibuffer = editor.buffer().read(cx);
503 let position = editor.selections.newest_anchor().head();
504 let snapshot = multibuffer.snapshot(cx);
505 let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?;
506 let buffer = multibuffer.buffer(text_anchor.buffer_id)?;
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![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]);
520 });
521 });
522 });
523 }
524
525 fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
526 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
527 self.editor.update(cx, |editor, cx| {
528 editor.rhs_editor().update(cx, |editor, cx| {
529 editor.change_selections(
530 SelectionEffects::scroll(Autoscroll::focused()),
531 window,
532 cx,
533 |s| {
534 s.select_ranges([position..position]);
535 },
536 )
537 })
538 });
539 } else {
540 self.pending_scroll = Some(path_key);
541 }
542 }
543
544 pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
545 self.multibuffer.read(cx).snapshot(cx).total_changed_lines()
546 }
547
548 /// Returns the total count of review comments across all hunks/files.
549 pub fn total_review_comment_count(&self) -> usize {
550 self.review_comment_count
551 }
552
553 /// Returns a reference to the splittable editor.
554 pub fn editor(&self) -> &Entity<SplittableEditor> {
555 &self.editor
556 }
557
558 fn button_states(&self, cx: &App) -> ButtonStates {
559 let editor = self.editor.read(cx).rhs_editor().read(cx);
560 let snapshot = self.multibuffer.read(cx).snapshot(cx);
561 let prev_next = snapshot.diff_hunks().nth(1).is_some();
562 let mut selection = true;
563
564 let mut ranges = editor
565 .selections
566 .disjoint_anchor_ranges()
567 .collect::<Vec<_>>();
568 if !ranges.iter().any(|range| range.start != range.end) {
569 selection = false;
570 let anchor = editor.selections.newest_anchor().head();
571 if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
572 && let Some(range) = snapshot
573 .anchor_in_buffer(excerpt_range.context.start)
574 .zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
575 .map(|(start, end)| start..end)
576 {
577 ranges = vec![range];
578 } else {
579 ranges = Vec::default();
580 };
581 }
582 let mut has_staged_hunks = false;
583 let mut has_unstaged_hunks = false;
584 for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
585 match hunk.status.secondary {
586 DiffHunkSecondaryStatus::HasSecondaryHunk
587 | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
588 has_unstaged_hunks = true;
589 }
590 DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
591 has_staged_hunks = true;
592 has_unstaged_hunks = true;
593 }
594 DiffHunkSecondaryStatus::NoSecondaryHunk
595 | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
596 has_staged_hunks = true;
597 }
598 }
599 }
600 let mut stage_all = false;
601 let mut unstage_all = false;
602 self.workspace
603 .read_with(cx, |workspace, cx| {
604 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
605 let git_panel = git_panel.read(cx);
606 stage_all = git_panel.can_stage_all();
607 unstage_all = git_panel.can_unstage_all();
608 }
609 })
610 .ok();
611
612 ButtonStates {
613 stage: has_unstaged_hunks,
614 unstage: has_staged_hunks,
615 prev_next,
616 selection,
617 stage_all,
618 unstage_all,
619 }
620 }
621
622 fn handle_editor_event(
623 &mut self,
624 editor: &Entity<SplittableEditor>,
625 event: &EditorEvent,
626 window: &mut Window,
627 cx: &mut Context<Self>,
628 ) {
629 match event {
630 EditorEvent::SelectionsChanged { local: true } => {
631 let Some(project_path) = self.active_path(cx) else {
632 return;
633 };
634 self.workspace
635 .update(cx, |workspace, cx| {
636 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
637 git_panel.update(cx, |git_panel, cx| {
638 git_panel.select_entry_by_path(project_path, window, cx)
639 })
640 }
641 })
642 .ok();
643 }
644 EditorEvent::Saved => {
645 self._task = cx.spawn_in(window, async move |this, cx| {
646 Self::refresh(this, RefreshReason::EditorSaved, cx).await
647 });
648 }
649 _ => {}
650 }
651 if editor.focus_handle(cx).contains_focused(window, cx)
652 && self.multibuffer.read(cx).is_empty()
653 {
654 self.focus_handle.focus(window, cx)
655 }
656 }
657
658 #[instrument(skip_all)]
659 fn register_buffer(
660 &mut self,
661 path_key: PathKey,
662 file_status: FileStatus,
663 buffer: Entity<Buffer>,
664 diff: Entity<BufferDiff>,
665 window: &mut Window,
666 cx: &mut Context<Self>,
667 ) -> Option<BufferId> {
668 let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
669 this._task = window.spawn(cx, {
670 let this = cx.weak_entity();
671 async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await
672 })
673 });
674 self.buffer_diff_subscriptions
675 .insert(path_key.path.clone(), (diff.clone(), subscription));
676
677 // TODO(split-diff) we shouldn't have a conflict addon when split
678 let conflict_addon = self
679 .editor
680 .read(cx)
681 .rhs_editor()
682 .read(cx)
683 .addon::<ConflictAddon>()
684 .expect("project diff editor should have a conflict addon");
685
686 let snapshot = buffer.read(cx).snapshot();
687 let diff_snapshot = diff.read(cx).snapshot(cx);
688
689 let excerpt_ranges = {
690 let diff_hunk_ranges = diff_snapshot
691 .hunks_intersecting_range(
692 Anchor::min_max_range_for_buffer(snapshot.remote_id()),
693 &snapshot,
694 )
695 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
696 let conflicts = conflict_addon
697 .conflict_set(snapshot.remote_id())
698 .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts)
699 .unwrap_or_default();
700 let mut conflicts = conflicts
701 .iter()
702 .map(|conflict| conflict.range.to_point(&snapshot))
703 .peekable();
704
705 if conflicts.peek().is_some() {
706 conflicts.collect::<Vec<_>>()
707 } else {
708 diff_hunk_ranges.collect()
709 }
710 };
711
712 let mut needs_fold = None;
713
714 let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
715 let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
716 let is_newly_added = editor.update_excerpts_for_path(
717 path_key.clone(),
718 buffer,
719 excerpt_ranges,
720 multibuffer_context_lines(cx),
721 diff,
722 cx,
723 );
724 (was_empty, is_newly_added)
725 });
726
727 self.editor.update(cx, |editor, cx| {
728 editor.rhs_editor().update(cx, |editor, cx| {
729 if was_empty {
730 editor.change_selections(
731 SelectionEffects::no_scroll(),
732 window,
733 cx,
734 |selections| {
735 selections.select_ranges([
736 multi_buffer::Anchor::Min..multi_buffer::Anchor::Min
737 ])
738 },
739 );
740 }
741 if is_excerpt_newly_added
742 && (file_status.is_deleted()
743 || (file_status.is_untracked()
744 && GitPanelSettings::get_global(cx).collapse_untracked_diff))
745 {
746 needs_fold = Some(snapshot.text.remote_id());
747 }
748 })
749 });
750
751 if self.multibuffer.read(cx).is_empty()
752 && self
753 .editor
754 .read(cx)
755 .focus_handle(cx)
756 .contains_focused(window, cx)
757 {
758 self.focus_handle.focus(window, cx);
759 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
760 self.editor.update(cx, |editor, cx| {
761 editor.focus_handle(cx).focus(window, cx);
762 });
763 }
764 if self.pending_scroll.as_ref() == Some(&path_key) {
765 self.move_to_path(path_key, window, cx);
766 }
767
768 needs_fold
769 }
770
771 #[instrument(skip(this, cx))]
772 pub async fn refresh(
773 this: WeakEntity<Self>,
774 reason: RefreshReason,
775 cx: &mut AsyncWindowContext,
776 ) -> Result<()> {
777 let mut path_keys = Vec::new();
778 let buffers_to_load = this.update(cx, |this, cx| {
779 let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
780 let load_buffers = branch_diff.load_buffers(cx);
781 (branch_diff.repo().cloned(), load_buffers)
782 });
783 let mut previous_buffers = this
784 .multibuffer
785 .read(cx)
786 .snapshot(cx)
787 .buffers_with_paths()
788 .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id()))
789 .collect::<HashMap<_, _>>();
790
791 if let Some(repo) = repo {
792 let repo = repo.read(cx);
793
794 path_keys = Vec::with_capacity(buffers_to_load.len());
795 for entry in buffers_to_load.iter() {
796 let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx);
797 let path_key =
798 PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
799 previous_buffers.remove(&path_key);
800 path_keys.push(path_key)
801 }
802 }
803
804 this.editor.update(cx, |editor, cx| {
805 for (path, buffer_id) in previous_buffers {
806 if let Some(buffer) = this.multibuffer.read(cx).buffer(buffer_id) {
807 let skip = match reason {
808 RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
809 buffer.read(cx).is_dirty()
810 }
811 RefreshReason::StatusesChanged => false,
812 };
813 if skip {
814 continue;
815 }
816 }
817
818 this.buffer_diff_subscriptions.remove(&path.path);
819 let _span = ztracing::info_span!("remove_excerpts_for_path");
820 _span.enter();
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 force_format: false,
1978 autosave: false,
1979 },
1980 project.clone(),
1981 window,
1982 cx,
1983 )
1984 })
1985 .await
1986 .unwrap();
1987
1988 cx.run_until_parked();
1989
1990 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1991 buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
1992 });
1993
1994 assert_state_with_diff(
1995 &buffer_editor,
1996 cx,
1997 &"
1998 - original
1999 + different
2000 ˇ"
2001 .unindent(),
2002 );
2003
2004 assert_state_with_diff(
2005 &diff_editor,
2006 cx,
2007 &"
2008 - ˇoriginal
2009 + different
2010 "
2011 .unindent(),
2012 );
2013 }
2014
2015 use crate::{
2016 conflict_view::resolve_conflict,
2017 project_diff::{self, ProjectDiff},
2018 };
2019
2020 #[gpui::test]
2021 async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
2022 init_test(cx);
2023
2024 let fs = FakeFs::new(cx.executor());
2025 fs.insert_tree(
2026 path!("/a"),
2027 json!({
2028 ".git": {},
2029 "a.txt": "created\n",
2030 "b.txt": "really changed\n",
2031 "c.txt": "unchanged\n"
2032 }),
2033 )
2034 .await;
2035
2036 fs.set_head_and_index_for_repo(
2037 Path::new(path!("/a/.git")),
2038 &[
2039 ("b.txt", "before\n".to_string()),
2040 ("c.txt", "unchanged\n".to_string()),
2041 ("d.txt", "deleted\n".to_string()),
2042 ],
2043 );
2044
2045 let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2046 let (multi_workspace, cx) =
2047 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2048 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2049
2050 cx.run_until_parked();
2051
2052 cx.focus(&workspace);
2053 cx.update(|window, cx| {
2054 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2055 });
2056
2057 cx.run_until_parked();
2058
2059 let item = workspace.update(cx, |workspace, cx| {
2060 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2061 });
2062 cx.focus(&item);
2063 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2064
2065 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2066
2067 cx.assert_excerpts_with_selections(indoc!(
2068 "
2069 [EXCERPT]
2070 before
2071 really changed
2072 [EXCERPT]
2073 [FOLDED]
2074 [EXCERPT]
2075 ˇcreated
2076 "
2077 ));
2078
2079 cx.dispatch_action(editor::actions::GoToPreviousHunk);
2080
2081 cx.assert_excerpts_with_selections(indoc!(
2082 "
2083 [EXCERPT]
2084 before
2085 really changed
2086 [EXCERPT]
2087 ˇ[FOLDED]
2088 [EXCERPT]
2089 created
2090 "
2091 ));
2092
2093 cx.dispatch_action(editor::actions::GoToPreviousHunk);
2094
2095 cx.assert_excerpts_with_selections(indoc!(
2096 "
2097 [EXCERPT]
2098 ˇbefore
2099 really changed
2100 [EXCERPT]
2101 [FOLDED]
2102 [EXCERPT]
2103 created
2104 "
2105 ));
2106 }
2107
2108 #[gpui::test]
2109 async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
2110 init_test(cx);
2111
2112 let git_contents = indoc! {r#"
2113 #[rustfmt::skip]
2114 fn main() {
2115 let x = 0.0; // this line will be removed
2116 // 1
2117 // 2
2118 // 3
2119 let y = 0.0; // this line will be removed
2120 // 1
2121 // 2
2122 // 3
2123 let arr = [
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 0.0, // this line will be removed
2128 ];
2129 }
2130 "#};
2131 let buffer_contents = indoc! {"
2132 #[rustfmt::skip]
2133 fn main() {
2134 // 1
2135 // 2
2136 // 3
2137 // 1
2138 // 2
2139 // 3
2140 let arr = [
2141 ];
2142 }
2143 "};
2144
2145 let fs = FakeFs::new(cx.executor());
2146 fs.insert_tree(
2147 path!("/a"),
2148 json!({
2149 ".git": {},
2150 "main.rs": buffer_contents,
2151 }),
2152 )
2153 .await;
2154
2155 fs.set_head_and_index_for_repo(
2156 Path::new(path!("/a/.git")),
2157 &[("main.rs", git_contents.to_owned())],
2158 );
2159
2160 let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2161 let (multi_workspace, cx) =
2162 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2163 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2164
2165 cx.run_until_parked();
2166
2167 cx.focus(&workspace);
2168 cx.update(|window, cx| {
2169 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2170 });
2171
2172 cx.run_until_parked();
2173
2174 let item = workspace.update(cx, |workspace, cx| {
2175 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2176 });
2177 cx.focus(&item);
2178 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2179
2180 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2181
2182 cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2183
2184 cx.dispatch_action(editor::actions::GoToHunk);
2185 cx.dispatch_action(editor::actions::GoToHunk);
2186 cx.dispatch_action(git::Restore);
2187 cx.dispatch_action(editor::actions::MoveToBeginning);
2188
2189 cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2190 }
2191
2192 #[gpui::test]
2193 async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) {
2194 init_test(cx);
2195
2196 let fs = FakeFs::new(cx.executor());
2197 fs.insert_tree(
2198 path!("/project"),
2199 json!({
2200 ".git": {},
2201 "foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n",
2202 }),
2203 )
2204 .await;
2205 fs.set_status_for_repo(
2206 Path::new(path!("/project/.git")),
2207 &[(
2208 "foo",
2209 UnmergedStatus {
2210 first_head: UnmergedStatusCode::Updated,
2211 second_head: UnmergedStatusCode::Updated,
2212 }
2213 .into(),
2214 )],
2215 );
2216 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2217 let (multi_workspace, cx) =
2218 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2219 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2220 let diff = cx.new_window_entity(|window, cx| {
2221 ProjectDiff::new(project.clone(), workspace, window, cx)
2222 });
2223 cx.run_until_parked();
2224
2225 cx.update(|window, cx| {
2226 let editor = diff.read(cx).editor.read(cx).rhs_editor().clone();
2227 let excerpts = editor
2228 .read(cx)
2229 .buffer()
2230 .read(cx)
2231 .snapshot(cx)
2232 .excerpts()
2233 .collect::<Vec<_>>();
2234 assert_eq!(excerpts.len(), 1);
2235 let buffer = editor
2236 .read(cx)
2237 .buffer()
2238 .read(cx)
2239 .all_buffers()
2240 .into_iter()
2241 .next()
2242 .unwrap();
2243 let buffer_id = buffer.read(cx).remote_id();
2244 let conflict_set = diff
2245 .read(cx)
2246 .editor
2247 .read(cx)
2248 .rhs_editor()
2249 .read(cx)
2250 .addon::<ConflictAddon>()
2251 .unwrap()
2252 .conflict_set(buffer_id)
2253 .unwrap();
2254 assert!(conflict_set.read(cx).has_conflict);
2255 let snapshot = conflict_set.read(cx).snapshot();
2256 assert_eq!(snapshot.conflicts.len(), 1);
2257
2258 let ours_range = snapshot.conflicts[0].ours.clone();
2259
2260 resolve_conflict(
2261 editor.downgrade(),
2262 snapshot.conflicts[0].clone(),
2263 vec![ours_range],
2264 window,
2265 cx,
2266 )
2267 })
2268 .await;
2269
2270 let contents = fs.read_file_sync(path!("/project/foo")).unwrap();
2271 let contents = String::from_utf8(contents).unwrap();
2272 assert_eq!(contents, "ours\n");
2273 }
2274
2275 #[gpui::test(iterations = 50)]
2276 async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics(
2277 cx: &mut TestAppContext,
2278 ) {
2279 init_test(cx);
2280
2281 cx.update(|cx| {
2282 cx.update_global::<SettingsStore, _>(|store, cx| {
2283 store.update_user_settings(cx, |settings| {
2284 settings.editor.diff_view_style = Some(DiffViewStyle::Split);
2285 });
2286 });
2287 });
2288
2289 let build_conflict_text: fn(usize) -> String = |tag: usize| {
2290 let mut lines = (0..80)
2291 .map(|line_index| format!("line {line_index}"))
2292 .collect::<Vec<_>>();
2293 for offset in [5usize, 20, 37, 61] {
2294 lines[offset] = format!("base-{tag}-line-{offset}");
2295 }
2296 format!("{}\n", lines.join("\n"))
2297 };
2298 let initial_conflict_text = build_conflict_text(0);
2299 let fs = FakeFs::new(cx.executor());
2300 fs.insert_tree(
2301 path!("/project"),
2302 json!({
2303 ".git": {},
2304 "helper.txt": "same\n",
2305 "conflict.txt": initial_conflict_text,
2306 }),
2307 )
2308 .await;
2309 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
2310 state
2311 .refs
2312 .insert("MERGE_HEAD".into(), "conflict-head".into());
2313 })
2314 .unwrap();
2315 fs.set_status_for_repo(
2316 path!("/project/.git").as_ref(),
2317 &[(
2318 "conflict.txt",
2319 FileStatus::Unmerged(UnmergedStatus {
2320 first_head: UnmergedStatusCode::Updated,
2321 second_head: UnmergedStatusCode::Updated,
2322 }),
2323 )],
2324 );
2325 fs.set_merge_base_content_for_repo(
2326 path!("/project/.git").as_ref(),
2327 &[
2328 ("conflict.txt", build_conflict_text(1)),
2329 ("helper.txt", "same\n".to_string()),
2330 ],
2331 );
2332
2333 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2334 let (multi_workspace, cx) =
2335 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2336 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2337 let _project_diff = cx
2338 .update(|window, cx| {
2339 ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
2340 })
2341 .await
2342 .unwrap();
2343 cx.run_until_parked();
2344
2345 let buffer = project
2346 .update(cx, |project, cx| {
2347 project.open_local_buffer(path!("/project/conflict.txt"), cx)
2348 })
2349 .await
2350 .unwrap();
2351 buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx));
2352 assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
2353 cx.run_until_parked();
2354
2355 cx.update(|window, cx| {
2356 let fs = fs.clone();
2357 window
2358 .spawn(cx, async move |cx| {
2359 cx.background_executor().simulate_random_delay().await;
2360 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
2361 state.refs.insert("HEAD".into(), "head-1".into());
2362 state.refs.remove("MERGE_HEAD");
2363 })
2364 .unwrap();
2365 fs.set_status_for_repo(
2366 path!("/project/.git").as_ref(),
2367 &[
2368 (
2369 "conflict.txt",
2370 FileStatus::Tracked(TrackedStatus {
2371 index_status: git::status::StatusCode::Modified,
2372 worktree_status: git::status::StatusCode::Modified,
2373 }),
2374 ),
2375 (
2376 "helper.txt",
2377 FileStatus::Tracked(TrackedStatus {
2378 index_status: git::status::StatusCode::Modified,
2379 worktree_status: git::status::StatusCode::Modified,
2380 }),
2381 ),
2382 ],
2383 );
2384 // FakeFs assigns deterministic OIDs by entry position; flipping order churns
2385 // conflict diff identity without reaching into ProjectDiff internals.
2386 fs.set_merge_base_content_for_repo(
2387 path!("/project/.git").as_ref(),
2388 &[
2389 ("helper.txt", "helper-base\n".to_string()),
2390 ("conflict.txt", build_conflict_text(2)),
2391 ],
2392 );
2393 })
2394 .detach();
2395 });
2396
2397 cx.update(|window, cx| {
2398 let buffer = buffer.clone();
2399 window
2400 .spawn(cx, async move |cx| {
2401 cx.background_executor().simulate_random_delay().await;
2402 for edit_index in 0..10 {
2403 if edit_index > 0 {
2404 cx.background_executor().simulate_random_delay().await;
2405 }
2406 buffer.update(cx, |buffer, cx| {
2407 let len = buffer.len();
2408 if edit_index % 2 == 0 {
2409 buffer.edit(
2410 [(0..0, format!("status-burst-head-{edit_index}\n"))],
2411 None,
2412 cx,
2413 );
2414 } else {
2415 buffer.edit(
2416 [(len..len, format!("status-burst-tail-{edit_index}\n"))],
2417 None,
2418 cx,
2419 );
2420 }
2421 });
2422 }
2423 })
2424 .detach();
2425 });
2426
2427 cx.run_until_parked();
2428 }
2429
2430 #[gpui::test]
2431 async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) {
2432 init_test(cx);
2433
2434 let fs = FakeFs::new(cx.executor());
2435 fs.insert_tree(
2436 path!("/project"),
2437 json!({
2438 ".git": {},
2439 "foo.txt": "
2440 one
2441 two
2442 three
2443 four
2444 five
2445 six
2446 seven
2447 eight
2448 nine
2449 ten
2450 ELEVEN
2451 twelve
2452 ".unindent()
2453 }),
2454 )
2455 .await;
2456 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2457 let (multi_workspace, cx) =
2458 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2459 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2460 let diff = cx.new_window_entity(|window, cx| {
2461 ProjectDiff::new(project.clone(), workspace, window, cx)
2462 });
2463 cx.run_until_parked();
2464
2465 fs.set_head_and_index_for_repo(
2466 Path::new(path!("/project/.git")),
2467 &[(
2468 "foo.txt",
2469 "
2470 one
2471 two
2472 three
2473 four
2474 five
2475 six
2476 seven
2477 eight
2478 nine
2479 ten
2480 eleven
2481 twelve
2482 "
2483 .unindent(),
2484 )],
2485 );
2486 cx.run_until_parked();
2487
2488 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2489
2490 assert_state_with_diff(
2491 &editor,
2492 cx,
2493 &"
2494 ˇnine
2495 ten
2496 - eleven
2497 + ELEVEN
2498 twelve
2499 "
2500 .unindent(),
2501 );
2502
2503 // The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff.
2504 let buffer = project
2505 .update(cx, |project, cx| {
2506 project.open_local_buffer(path!("/project/foo.txt"), cx)
2507 })
2508 .await
2509 .unwrap();
2510 buffer.update(cx, |buffer, cx| {
2511 buffer.edit_via_marked_text(
2512 &"
2513 one
2514 «TWO»
2515 three
2516 four
2517 five
2518 six
2519 seven
2520 eight
2521 nine
2522 ten
2523 ELEVEN
2524 twelve
2525 "
2526 .unindent(),
2527 None,
2528 cx,
2529 );
2530 });
2531 project
2532 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2533 .await
2534 .unwrap();
2535 cx.run_until_parked();
2536
2537 assert_state_with_diff(
2538 &editor,
2539 cx,
2540 &"
2541 one
2542 - two
2543 + TWO
2544 three
2545 four
2546 five
2547 ˇnine
2548 ten
2549 - eleven
2550 + ELEVEN
2551 twelve
2552 "
2553 .unindent(),
2554 );
2555 }
2556
2557 #[gpui::test]
2558 async fn test_branch_diff(cx: &mut TestAppContext) {
2559 init_test(cx);
2560
2561 let fs = FakeFs::new(cx.executor());
2562 fs.insert_tree(
2563 path!("/project"),
2564 json!({
2565 ".git": {},
2566 "a.txt": "C",
2567 "b.txt": "new",
2568 "c.txt": "in-merge-base-and-work-tree",
2569 "d.txt": "created-in-head",
2570 }),
2571 )
2572 .await;
2573 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2574 let (multi_workspace, cx) =
2575 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2576 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2577 let diff = cx
2578 .update(|window, cx| {
2579 ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
2580 })
2581 .await
2582 .unwrap();
2583 cx.run_until_parked();
2584
2585 fs.set_head_for_repo(
2586 Path::new(path!("/project/.git")),
2587 &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
2588 "sha",
2589 );
2590 // fs.set_index_for_repo(dot_git, index_state);
2591 fs.set_merge_base_content_for_repo(
2592 Path::new(path!("/project/.git")),
2593 &[
2594 ("a.txt", "A".into()),
2595 ("c.txt", "in-merge-base-and-work-tree".into()),
2596 ],
2597 );
2598 cx.run_until_parked();
2599
2600 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2601
2602 assert_state_with_diff(
2603 &editor,
2604 cx,
2605 &"
2606 - A
2607 + ˇC
2608 + new
2609 + created-in-head"
2610 .unindent(),
2611 );
2612
2613 let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
2614 editor.update(cx, |editor, cx| {
2615 editor
2616 .buffer()
2617 .read(cx)
2618 .all_buffers()
2619 .iter()
2620 .map(|buffer| {
2621 (
2622 buffer.read(cx).file().unwrap().path().clone(),
2623 editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
2624 )
2625 })
2626 .collect()
2627 });
2628
2629 assert_eq!(
2630 statuses,
2631 HashMap::from_iter([
2632 (
2633 rel_path("a.txt").into_arc(),
2634 Some(FileStatus::Tracked(TrackedStatus {
2635 index_status: git::status::StatusCode::Modified,
2636 worktree_status: git::status::StatusCode::Modified
2637 }))
2638 ),
2639 (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
2640 (
2641 rel_path("d.txt").into_arc(),
2642 Some(FileStatus::Tracked(TrackedStatus {
2643 index_status: git::status::StatusCode::Added,
2644 worktree_status: git::status::StatusCode::Added
2645 }))
2646 )
2647 ])
2648 );
2649 }
2650
2651 #[gpui::test]
2652 async fn test_update_on_uncommit(cx: &mut TestAppContext) {
2653 init_test(cx);
2654
2655 let fs = FakeFs::new(cx.executor());
2656 fs.insert_tree(
2657 path!("/project"),
2658 json!({
2659 ".git": {},
2660 "README.md": "# My cool project\n".to_owned()
2661 }),
2662 )
2663 .await;
2664 fs.set_head_and_index_for_repo(
2665 Path::new(path!("/project/.git")),
2666 &[("README.md", "# My cool project\n".to_owned())],
2667 );
2668 let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
2669 let worktree_id = project.read_with(cx, |project, cx| {
2670 project.worktrees(cx).next().unwrap().read(cx).id()
2671 });
2672 let (multi_workspace, cx) =
2673 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2674 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2675 cx.run_until_parked();
2676
2677 let _editor = workspace
2678 .update_in(cx, |workspace, window, cx| {
2679 workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
2680 })
2681 .await
2682 .unwrap()
2683 .downcast::<Editor>()
2684 .unwrap();
2685
2686 cx.focus(&workspace);
2687 cx.update(|window, cx| {
2688 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2689 });
2690 cx.run_until_parked();
2691 let item = workspace.update(cx, |workspace, cx| {
2692 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2693 });
2694 cx.focus(&item);
2695 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2696
2697 fs.set_head_and_index_for_repo(
2698 Path::new(path!("/project/.git")),
2699 &[(
2700 "README.md",
2701 "# My cool project\nDetails to come.\n".to_owned(),
2702 )],
2703 );
2704 cx.run_until_parked();
2705
2706 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2707
2708 cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
2709 }
2710
2711 #[gpui::test]
2712 async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) {
2713 init_test(cx);
2714
2715 let fs = FakeFs::new(cx.executor());
2716 fs.insert_tree(
2717 path!("/project_a"),
2718 json!({
2719 ".git": {},
2720 "a.txt": "CHANGED_A\n",
2721 }),
2722 )
2723 .await;
2724 fs.insert_tree(
2725 path!("/project_b"),
2726 json!({
2727 ".git": {},
2728 "b.txt": "CHANGED_B\n",
2729 }),
2730 )
2731 .await;
2732
2733 fs.set_head_and_index_for_repo(
2734 Path::new(path!("/project_a/.git")),
2735 &[("a.txt", "original_a\n".to_string())],
2736 );
2737 fs.set_head_and_index_for_repo(
2738 Path::new(path!("/project_b/.git")),
2739 &[("b.txt", "original_b\n".to_string())],
2740 );
2741
2742 let project = Project::test(
2743 fs.clone(),
2744 [
2745 Path::new(path!("/project_a")),
2746 Path::new(path!("/project_b")),
2747 ],
2748 cx,
2749 )
2750 .await;
2751
2752 let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| {
2753 let mut worktrees: Vec<_> = project.worktrees(cx).collect();
2754 worktrees.sort_by_key(|w| w.read(cx).abs_path());
2755 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
2756 });
2757
2758 let (multi_workspace, cx) =
2759 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2760 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2761 cx.run_until_parked();
2762
2763 // Select project A explicitly and open the diff.
2764 workspace.update(cx, |workspace, cx| {
2765 let git_store = workspace.project().read(cx).git_store().clone();
2766 git_store.update(cx, |git_store, cx| {
2767 git_store.set_active_repo_for_worktree(worktree_a_id, cx);
2768 });
2769 });
2770 cx.focus(&workspace);
2771 cx.update(|window, cx| {
2772 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2773 });
2774 cx.run_until_parked();
2775
2776 let diff_item = workspace.update(cx, |workspace, cx| {
2777 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2778 });
2779 let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
2780 assert_eq!(paths_a.len(), 1);
2781 assert_eq!(*paths_a[0], *"a.txt");
2782
2783 // Switch the explicit active repository to project B and re-run the diff action.
2784 workspace.update(cx, |workspace, cx| {
2785 let git_store = workspace.project().read(cx).git_store().clone();
2786 git_store.update(cx, |git_store, cx| {
2787 git_store.set_active_repo_for_worktree(worktree_b_id, cx);
2788 });
2789 });
2790 cx.focus(&workspace);
2791 cx.update(|window, cx| {
2792 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2793 });
2794 cx.run_until_parked();
2795
2796 let same_diff_item = workspace.update(cx, |workspace, cx| {
2797 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2798 });
2799 assert_eq!(diff_item.entity_id(), same_diff_item.entity_id());
2800
2801 let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
2802 assert_eq!(paths_b.len(), 1);
2803 assert_eq!(*paths_b[0], *"b.txt");
2804 }
2805}