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