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