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