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