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