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