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