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 this.text_center()
1675 .child(Label::new("No Repository").color(Color::Muted))
1676 .child(
1677 Button::new("initialize-repo", "Initialize Repository")
1678 .on_click(move |_, _, cx| cx.dispatch_action(&git::Init)),
1679 )
1680 })
1681 .map(|this| {
1682 if not_ahead_or_behind && self.current_branch.is_some() {
1683 this.text_center()
1684 .child(Label::new("No Changes").color(Color::Muted))
1685 } else {
1686 this.when_some(self.current_branch.as_ref(), |this, branch| {
1687 this.child(has_branch_container(branch))
1688 })
1689 }
1690 }),
1691 )
1692 }
1693}
1694
1695mod preview {
1696 use git::repository::{
1697 Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
1698 };
1699 use ui::prelude::*;
1700
1701 use super::ProjectDiffEmptyState;
1702
1703 // View this component preview using `workspace: open component-preview`
1704 impl Component for ProjectDiffEmptyState {
1705 fn scope() -> ComponentScope {
1706 ComponentScope::VersionControl
1707 }
1708
1709 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
1710 let unknown_upstream: Option<UpstreamTracking> = None;
1711 let ahead_of_upstream: Option<UpstreamTracking> = Some(
1712 UpstreamTrackingStatus {
1713 ahead: 2,
1714 behind: 0,
1715 }
1716 .into(),
1717 );
1718
1719 let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
1720 UpstreamTrackingStatus {
1721 ahead: 0,
1722 behind: 0,
1723 }
1724 .into(),
1725 );
1726
1727 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
1728 Branch {
1729 is_head: true,
1730 ref_name: "some-branch".into(),
1731 upstream: upstream.map(|tracking| Upstream {
1732 ref_name: "origin/some-branch".into(),
1733 tracking,
1734 }),
1735 most_recent_commit: Some(CommitSummary {
1736 sha: "abc123".into(),
1737 subject: "Modify stuff".into(),
1738 commit_timestamp: 1710932954,
1739 author_name: "John Doe".into(),
1740 has_parent: true,
1741 }),
1742 }
1743 }
1744
1745 let no_repo_state = ProjectDiffEmptyState {
1746 no_repo: true,
1747 can_push_and_pull: false,
1748 focus_handle: None,
1749 current_branch: None,
1750 };
1751
1752 let no_changes_state = ProjectDiffEmptyState {
1753 no_repo: false,
1754 can_push_and_pull: true,
1755 focus_handle: None,
1756 current_branch: Some(branch(not_ahead_or_behind_upstream)),
1757 };
1758
1759 let ahead_of_upstream_state = ProjectDiffEmptyState {
1760 no_repo: false,
1761 can_push_and_pull: true,
1762 focus_handle: None,
1763 current_branch: Some(branch(ahead_of_upstream)),
1764 };
1765
1766 let unknown_upstream_state = ProjectDiffEmptyState {
1767 no_repo: false,
1768 can_push_and_pull: true,
1769 focus_handle: None,
1770 current_branch: Some(branch(unknown_upstream)),
1771 };
1772
1773 let (width, height) = (px(480.), px(320.));
1774
1775 Some(
1776 v_flex()
1777 .gap_6()
1778 .children(vec![
1779 example_group(vec![
1780 single_example(
1781 "No Repo",
1782 div()
1783 .w(width)
1784 .h(height)
1785 .child(no_repo_state)
1786 .into_any_element(),
1787 ),
1788 single_example(
1789 "No Changes",
1790 div()
1791 .w(width)
1792 .h(height)
1793 .child(no_changes_state)
1794 .into_any_element(),
1795 ),
1796 single_example(
1797 "Unknown Upstream",
1798 div()
1799 .w(width)
1800 .h(height)
1801 .child(unknown_upstream_state)
1802 .into_any_element(),
1803 ),
1804 single_example(
1805 "Ahead of Remote",
1806 div()
1807 .w(width)
1808 .h(height)
1809 .child(ahead_of_upstream_state)
1810 .into_any_element(),
1811 ),
1812 ])
1813 .vertical(),
1814 ])
1815 .into_any_element(),
1816 )
1817 }
1818 }
1819}
1820
1821struct BranchDiffAddon {
1822 branch_diff: Entity<branch_diff::BranchDiff>,
1823}
1824
1825impl Addon for BranchDiffAddon {
1826 fn to_any(&self) -> &dyn std::any::Any {
1827 self
1828 }
1829
1830 fn override_status_for_buffer_id(
1831 &self,
1832 buffer_id: language::BufferId,
1833 cx: &App,
1834 ) -> Option<FileStatus> {
1835 self.branch_diff
1836 .read(cx)
1837 .status_for_buffer_id(buffer_id, cx)
1838 }
1839}
1840
1841#[cfg(test)]
1842mod tests {
1843 use collections::HashMap;
1844 use db::indoc;
1845 use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
1846 use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
1847 use gpui::TestAppContext;
1848 use project::FakeFs;
1849 use serde_json::json;
1850 use settings::{DiffViewStyle, SettingsStore};
1851 use std::path::Path;
1852 use unindent::Unindent as _;
1853 use util::{
1854 path,
1855 rel_path::{RelPath, rel_path},
1856 };
1857
1858 use workspace::MultiWorkspace;
1859
1860 use super::*;
1861
1862 #[ctor::ctor]
1863 fn init_logger() {
1864 zlog::init_test();
1865 }
1866
1867 fn init_test(cx: &mut TestAppContext) {
1868 cx.update(|cx| {
1869 let store = SettingsStore::test(cx);
1870 cx.set_global(store);
1871 cx.update_global::<SettingsStore, _>(|store, cx| {
1872 store.update_user_settings(cx, |settings| {
1873 settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
1874 });
1875 });
1876 theme::init(theme::LoadThemes::JustBase, cx);
1877 editor::init(cx);
1878 crate::init(cx);
1879 });
1880 }
1881
1882 #[gpui::test]
1883 async fn test_save_after_restore(cx: &mut TestAppContext) {
1884 init_test(cx);
1885
1886 let fs = FakeFs::new(cx.executor());
1887 fs.insert_tree(
1888 path!("/project"),
1889 json!({
1890 ".git": {},
1891 "foo.txt": "FOO\n",
1892 }),
1893 )
1894 .await;
1895 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1896
1897 fs.set_head_for_repo(
1898 path!("/project/.git").as_ref(),
1899 &[("foo.txt", "foo\n".into())],
1900 "deadbeef",
1901 );
1902 fs.set_index_for_repo(
1903 path!("/project/.git").as_ref(),
1904 &[("foo.txt", "foo\n".into())],
1905 );
1906
1907 let (multi_workspace, cx) =
1908 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1909 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1910 let diff = cx.new_window_entity(|window, cx| {
1911 ProjectDiff::new(project.clone(), workspace, window, cx)
1912 });
1913 cx.run_until_parked();
1914
1915 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
1916 assert_state_with_diff(
1917 &editor,
1918 cx,
1919 &"
1920 - ˇfoo
1921 + FOO
1922 "
1923 .unindent(),
1924 );
1925
1926 editor
1927 .update_in(cx, |editor, window, cx| {
1928 editor.git_restore(&Default::default(), window, cx);
1929 editor.save(SaveOptions::default(), project.clone(), window, cx)
1930 })
1931 .await
1932 .unwrap();
1933 cx.run_until_parked();
1934
1935 assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1936
1937 let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1938 assert_eq!(text, "foo\n");
1939 }
1940
1941 #[gpui::test]
1942 async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1943 init_test(cx);
1944
1945 let fs = FakeFs::new(cx.executor());
1946 fs.insert_tree(
1947 path!("/project"),
1948 json!({
1949 ".git": {},
1950 "bar": "BAR\n",
1951 "foo": "FOO\n",
1952 }),
1953 )
1954 .await;
1955 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1956 let (multi_workspace, cx) =
1957 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1958 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1959 let diff = cx.new_window_entity(|window, cx| {
1960 ProjectDiff::new(project.clone(), workspace, window, cx)
1961 });
1962 cx.run_until_parked();
1963
1964 fs.set_head_and_index_for_repo(
1965 path!("/project/.git").as_ref(),
1966 &[("bar", "bar\n".into()), ("foo", "foo\n".into())],
1967 );
1968 cx.run_until_parked();
1969
1970 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1971 diff.move_to_path(
1972 PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()),
1973 window,
1974 cx,
1975 );
1976 diff.editor.read(cx).rhs_editor().clone()
1977 });
1978 assert_state_with_diff(
1979 &editor,
1980 cx,
1981 &"
1982 - bar
1983 + BAR
1984
1985 - ˇfoo
1986 + FOO
1987 "
1988 .unindent(),
1989 );
1990
1991 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1992 diff.move_to_path(
1993 PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()),
1994 window,
1995 cx,
1996 );
1997 diff.editor.read(cx).rhs_editor().clone()
1998 });
1999 assert_state_with_diff(
2000 &editor,
2001 cx,
2002 &"
2003 - ˇbar
2004 + BAR
2005
2006 - foo
2007 + FOO
2008 "
2009 .unindent(),
2010 );
2011 }
2012
2013 #[gpui::test]
2014 async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
2015 init_test(cx);
2016
2017 let fs = FakeFs::new(cx.executor());
2018 fs.insert_tree(
2019 path!("/project"),
2020 json!({
2021 ".git": {},
2022 "foo": "modified\n",
2023 }),
2024 )
2025 .await;
2026 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2027 let (multi_workspace, cx) =
2028 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2029 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2030 fs.set_head_for_repo(
2031 path!("/project/.git").as_ref(),
2032 &[("foo", "original\n".into())],
2033 "deadbeef",
2034 );
2035
2036 let buffer = project
2037 .update(cx, |project, cx| {
2038 project.open_local_buffer(path!("/project/foo"), cx)
2039 })
2040 .await
2041 .unwrap();
2042 let buffer_editor = cx.new_window_entity(|window, cx| {
2043 Editor::for_buffer(buffer, Some(project.clone()), window, cx)
2044 });
2045 let diff = cx.new_window_entity(|window, cx| {
2046 ProjectDiff::new(project.clone(), workspace, window, cx)
2047 });
2048 cx.run_until_parked();
2049
2050 let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2051
2052 assert_state_with_diff(
2053 &diff_editor,
2054 cx,
2055 &"
2056 - ˇoriginal
2057 + modified
2058 "
2059 .unindent(),
2060 );
2061
2062 let prev_buffer_hunks =
2063 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2064 let snapshot = buffer_editor.snapshot(window, cx);
2065 let snapshot = &snapshot.buffer_snapshot();
2066 let prev_buffer_hunks = buffer_editor
2067 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
2068 .collect::<Vec<_>>();
2069 buffer_editor.git_restore(&Default::default(), window, cx);
2070 prev_buffer_hunks
2071 });
2072 assert_eq!(prev_buffer_hunks.len(), 1);
2073 cx.run_until_parked();
2074
2075 let new_buffer_hunks =
2076 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2077 let snapshot = buffer_editor.snapshot(window, cx);
2078 let snapshot = &snapshot.buffer_snapshot();
2079 buffer_editor
2080 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
2081 .collect::<Vec<_>>()
2082 });
2083 assert_eq!(new_buffer_hunks.as_slice(), &[]);
2084
2085 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2086 buffer_editor.set_text("different\n", window, cx);
2087 buffer_editor.save(
2088 SaveOptions {
2089 format: false,
2090 autosave: false,
2091 },
2092 project.clone(),
2093 window,
2094 cx,
2095 )
2096 })
2097 .await
2098 .unwrap();
2099
2100 cx.run_until_parked();
2101
2102 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2103 buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
2104 });
2105
2106 assert_state_with_diff(
2107 &buffer_editor,
2108 cx,
2109 &"
2110 - original
2111 + different
2112 ˇ"
2113 .unindent(),
2114 );
2115
2116 assert_state_with_diff(
2117 &diff_editor,
2118 cx,
2119 &"
2120 - ˇoriginal
2121 + different
2122 "
2123 .unindent(),
2124 );
2125 }
2126
2127 use crate::{
2128 conflict_view::resolve_conflict,
2129 project_diff::{self, ProjectDiff},
2130 };
2131
2132 #[gpui::test]
2133 async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
2134 init_test(cx);
2135
2136 let fs = FakeFs::new(cx.executor());
2137 fs.insert_tree(
2138 path!("/a"),
2139 json!({
2140 ".git": {},
2141 "a.txt": "created\n",
2142 "b.txt": "really changed\n",
2143 "c.txt": "unchanged\n"
2144 }),
2145 )
2146 .await;
2147
2148 fs.set_head_and_index_for_repo(
2149 Path::new(path!("/a/.git")),
2150 &[
2151 ("b.txt", "before\n".to_string()),
2152 ("c.txt", "unchanged\n".to_string()),
2153 ("d.txt", "deleted\n".to_string()),
2154 ],
2155 );
2156
2157 let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2158 let (multi_workspace, cx) =
2159 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2160 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2161
2162 cx.run_until_parked();
2163
2164 cx.focus(&workspace);
2165 cx.update(|window, cx| {
2166 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2167 });
2168
2169 cx.run_until_parked();
2170
2171 let item = workspace.update(cx, |workspace, cx| {
2172 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2173 });
2174 cx.focus(&item);
2175 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2176
2177 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2178
2179 cx.assert_excerpts_with_selections(indoc!(
2180 "
2181 [EXCERPT]
2182 before
2183 really changed
2184 [EXCERPT]
2185 [FOLDED]
2186 [EXCERPT]
2187 ˇcreated
2188 "
2189 ));
2190
2191 cx.dispatch_action(editor::actions::GoToPreviousHunk);
2192
2193 cx.assert_excerpts_with_selections(indoc!(
2194 "
2195 [EXCERPT]
2196 before
2197 really changed
2198 [EXCERPT]
2199 ˇ[FOLDED]
2200 [EXCERPT]
2201 created
2202 "
2203 ));
2204
2205 cx.dispatch_action(editor::actions::GoToPreviousHunk);
2206
2207 cx.assert_excerpts_with_selections(indoc!(
2208 "
2209 [EXCERPT]
2210 ˇbefore
2211 really changed
2212 [EXCERPT]
2213 [FOLDED]
2214 [EXCERPT]
2215 created
2216 "
2217 ));
2218 }
2219
2220 #[gpui::test]
2221 async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
2222 init_test(cx);
2223
2224 let git_contents = indoc! {r#"
2225 #[rustfmt::skip]
2226 fn main() {
2227 let x = 0.0; // this line will be removed
2228 // 1
2229 // 2
2230 // 3
2231 let y = 0.0; // this line will be removed
2232 // 1
2233 // 2
2234 // 3
2235 let arr = [
2236 0.0, // this line will be removed
2237 0.0, // this line will be removed
2238 0.0, // this line will be removed
2239 0.0, // this line will be removed
2240 ];
2241 }
2242 "#};
2243 let buffer_contents = indoc! {"
2244 #[rustfmt::skip]
2245 fn main() {
2246 // 1
2247 // 2
2248 // 3
2249 // 1
2250 // 2
2251 // 3
2252 let arr = [
2253 ];
2254 }
2255 "};
2256
2257 let fs = FakeFs::new(cx.executor());
2258 fs.insert_tree(
2259 path!("/a"),
2260 json!({
2261 ".git": {},
2262 "main.rs": buffer_contents,
2263 }),
2264 )
2265 .await;
2266
2267 fs.set_head_and_index_for_repo(
2268 Path::new(path!("/a/.git")),
2269 &[("main.rs", git_contents.to_owned())],
2270 );
2271
2272 let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2273 let (multi_workspace, cx) =
2274 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2275 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2276
2277 cx.run_until_parked();
2278
2279 cx.focus(&workspace);
2280 cx.update(|window, cx| {
2281 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2282 });
2283
2284 cx.run_until_parked();
2285
2286 let item = workspace.update(cx, |workspace, cx| {
2287 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2288 });
2289 cx.focus(&item);
2290 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2291
2292 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2293
2294 cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2295
2296 cx.dispatch_action(editor::actions::GoToHunk);
2297 cx.dispatch_action(editor::actions::GoToHunk);
2298 cx.dispatch_action(git::Restore);
2299 cx.dispatch_action(editor::actions::MoveToBeginning);
2300
2301 cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2302 }
2303
2304 #[gpui::test]
2305 async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) {
2306 init_test(cx);
2307
2308 let fs = FakeFs::new(cx.executor());
2309 fs.insert_tree(
2310 path!("/project"),
2311 json!({
2312 ".git": {},
2313 "foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n",
2314 }),
2315 )
2316 .await;
2317 fs.set_status_for_repo(
2318 Path::new(path!("/project/.git")),
2319 &[(
2320 "foo",
2321 UnmergedStatus {
2322 first_head: UnmergedStatusCode::Updated,
2323 second_head: UnmergedStatusCode::Updated,
2324 }
2325 .into(),
2326 )],
2327 );
2328 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2329 let (multi_workspace, cx) =
2330 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2331 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2332 let diff = cx.new_window_entity(|window, cx| {
2333 ProjectDiff::new(project.clone(), workspace, window, cx)
2334 });
2335 cx.run_until_parked();
2336
2337 cx.update(|window, cx| {
2338 let editor = diff.read(cx).editor.read(cx).rhs_editor().clone();
2339 let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
2340 assert_eq!(excerpt_ids.len(), 1);
2341 let excerpt_id = excerpt_ids[0];
2342 let buffer = editor
2343 .read(cx)
2344 .buffer()
2345 .read(cx)
2346 .all_buffers()
2347 .into_iter()
2348 .next()
2349 .unwrap();
2350 let buffer_id = buffer.read(cx).remote_id();
2351 let conflict_set = diff
2352 .read(cx)
2353 .editor
2354 .read(cx)
2355 .rhs_editor()
2356 .read(cx)
2357 .addon::<ConflictAddon>()
2358 .unwrap()
2359 .conflict_set(buffer_id)
2360 .unwrap();
2361 assert!(conflict_set.read(cx).has_conflict);
2362 let snapshot = conflict_set.read(cx).snapshot();
2363 assert_eq!(snapshot.conflicts.len(), 1);
2364
2365 let ours_range = snapshot.conflicts[0].ours.clone();
2366
2367 resolve_conflict(
2368 editor.downgrade(),
2369 excerpt_id,
2370 snapshot.conflicts[0].clone(),
2371 vec![ours_range],
2372 window,
2373 cx,
2374 )
2375 })
2376 .await;
2377
2378 let contents = fs.read_file_sync(path!("/project/foo")).unwrap();
2379 let contents = String::from_utf8(contents).unwrap();
2380 assert_eq!(contents, "ours\n");
2381 }
2382
2383 #[gpui::test]
2384 async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) {
2385 init_test(cx);
2386
2387 let fs = FakeFs::new(cx.executor());
2388 fs.insert_tree(
2389 path!("/project"),
2390 json!({
2391 ".git": {},
2392 "foo.txt": "
2393 one
2394 two
2395 three
2396 four
2397 five
2398 six
2399 seven
2400 eight
2401 nine
2402 ten
2403 ELEVEN
2404 twelve
2405 ".unindent()
2406 }),
2407 )
2408 .await;
2409 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2410 let (multi_workspace, cx) =
2411 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2412 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2413 let diff = cx.new_window_entity(|window, cx| {
2414 ProjectDiff::new(project.clone(), workspace, window, cx)
2415 });
2416 cx.run_until_parked();
2417
2418 fs.set_head_and_index_for_repo(
2419 Path::new(path!("/project/.git")),
2420 &[(
2421 "foo.txt",
2422 "
2423 one
2424 two
2425 three
2426 four
2427 five
2428 six
2429 seven
2430 eight
2431 nine
2432 ten
2433 eleven
2434 twelve
2435 "
2436 .unindent(),
2437 )],
2438 );
2439 cx.run_until_parked();
2440
2441 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2442
2443 assert_state_with_diff(
2444 &editor,
2445 cx,
2446 &"
2447 ˇnine
2448 ten
2449 - eleven
2450 + ELEVEN
2451 twelve
2452 "
2453 .unindent(),
2454 );
2455
2456 // The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff.
2457 let buffer = project
2458 .update(cx, |project, cx| {
2459 project.open_local_buffer(path!("/project/foo.txt"), cx)
2460 })
2461 .await
2462 .unwrap();
2463 buffer.update(cx, |buffer, cx| {
2464 buffer.edit_via_marked_text(
2465 &"
2466 one
2467 «TWO»
2468 three
2469 four
2470 five
2471 six
2472 seven
2473 eight
2474 nine
2475 ten
2476 ELEVEN
2477 twelve
2478 "
2479 .unindent(),
2480 None,
2481 cx,
2482 );
2483 });
2484 project
2485 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2486 .await
2487 .unwrap();
2488 cx.run_until_parked();
2489
2490 assert_state_with_diff(
2491 &editor,
2492 cx,
2493 &"
2494 one
2495 - two
2496 + TWO
2497 three
2498 four
2499 five
2500 ˇnine
2501 ten
2502 - eleven
2503 + ELEVEN
2504 twelve
2505 "
2506 .unindent(),
2507 );
2508 }
2509
2510 #[gpui::test]
2511 async fn test_branch_diff(cx: &mut TestAppContext) {
2512 init_test(cx);
2513
2514 let fs = FakeFs::new(cx.executor());
2515 fs.insert_tree(
2516 path!("/project"),
2517 json!({
2518 ".git": {},
2519 "a.txt": "C",
2520 "b.txt": "new",
2521 "c.txt": "in-merge-base-and-work-tree",
2522 "d.txt": "created-in-head",
2523 }),
2524 )
2525 .await;
2526 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2527 let (multi_workspace, cx) =
2528 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2529 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2530 let diff = cx
2531 .update(|window, cx| {
2532 ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
2533 })
2534 .await
2535 .unwrap();
2536 cx.run_until_parked();
2537
2538 fs.set_head_for_repo(
2539 Path::new(path!("/project/.git")),
2540 &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
2541 "sha",
2542 );
2543 // fs.set_index_for_repo(dot_git, index_state);
2544 fs.set_merge_base_content_for_repo(
2545 Path::new(path!("/project/.git")),
2546 &[
2547 ("a.txt", "A".into()),
2548 ("c.txt", "in-merge-base-and-work-tree".into()),
2549 ],
2550 );
2551 cx.run_until_parked();
2552
2553 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2554
2555 assert_state_with_diff(
2556 &editor,
2557 cx,
2558 &"
2559 - A
2560 + ˇC
2561 + new
2562 + created-in-head"
2563 .unindent(),
2564 );
2565
2566 let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
2567 editor.update(cx, |editor, cx| {
2568 editor
2569 .buffer()
2570 .read(cx)
2571 .all_buffers()
2572 .iter()
2573 .map(|buffer| {
2574 (
2575 buffer.read(cx).file().unwrap().path().clone(),
2576 editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
2577 )
2578 })
2579 .collect()
2580 });
2581
2582 assert_eq!(
2583 statuses,
2584 HashMap::from_iter([
2585 (
2586 rel_path("a.txt").into_arc(),
2587 Some(FileStatus::Tracked(TrackedStatus {
2588 index_status: git::status::StatusCode::Modified,
2589 worktree_status: git::status::StatusCode::Modified
2590 }))
2591 ),
2592 (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
2593 (
2594 rel_path("d.txt").into_arc(),
2595 Some(FileStatus::Tracked(TrackedStatus {
2596 index_status: git::status::StatusCode::Added,
2597 worktree_status: git::status::StatusCode::Added
2598 }))
2599 )
2600 ])
2601 );
2602 }
2603
2604 #[gpui::test]
2605 async fn test_update_on_uncommit(cx: &mut TestAppContext) {
2606 init_test(cx);
2607
2608 let fs = FakeFs::new(cx.executor());
2609 fs.insert_tree(
2610 path!("/project"),
2611 json!({
2612 ".git": {},
2613 "README.md": "# My cool project\n".to_owned()
2614 }),
2615 )
2616 .await;
2617 fs.set_head_and_index_for_repo(
2618 Path::new(path!("/project/.git")),
2619 &[("README.md", "# My cool project\n".to_owned())],
2620 );
2621 let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
2622 let worktree_id = project.read_with(cx, |project, cx| {
2623 project.worktrees(cx).next().unwrap().read(cx).id()
2624 });
2625 let (multi_workspace, cx) =
2626 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2627 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2628 cx.run_until_parked();
2629
2630 let _editor = workspace
2631 .update_in(cx, |workspace, window, cx| {
2632 workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
2633 })
2634 .await
2635 .unwrap()
2636 .downcast::<Editor>()
2637 .unwrap();
2638
2639 cx.focus(&workspace);
2640 cx.update(|window, cx| {
2641 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2642 });
2643 cx.run_until_parked();
2644 let item = workspace.update(cx, |workspace, cx| {
2645 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2646 });
2647 cx.focus(&item);
2648 let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2649
2650 fs.set_head_and_index_for_repo(
2651 Path::new(path!("/project/.git")),
2652 &[(
2653 "README.md",
2654 "# My cool project\nDetails to come.\n".to_owned(),
2655 )],
2656 );
2657 cx.run_until_parked();
2658
2659 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2660
2661 cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
2662 }
2663
2664 #[gpui::test]
2665 async fn test_deploy_at_respects_worktree_override(cx: &mut TestAppContext) {
2666 init_test(cx);
2667
2668 let fs = FakeFs::new(cx.executor());
2669 fs.insert_tree(
2670 path!("/project_a"),
2671 json!({
2672 ".git": {},
2673 "a.txt": "CHANGED_A\n",
2674 }),
2675 )
2676 .await;
2677 fs.insert_tree(
2678 path!("/project_b"),
2679 json!({
2680 ".git": {},
2681 "b.txt": "CHANGED_B\n",
2682 }),
2683 )
2684 .await;
2685
2686 fs.set_head_and_index_for_repo(
2687 Path::new(path!("/project_a/.git")),
2688 &[("a.txt", "original_a\n".to_string())],
2689 );
2690 fs.set_head_and_index_for_repo(
2691 Path::new(path!("/project_b/.git")),
2692 &[("b.txt", "original_b\n".to_string())],
2693 );
2694
2695 let project = Project::test(
2696 fs.clone(),
2697 [
2698 Path::new(path!("/project_a")),
2699 Path::new(path!("/project_b")),
2700 ],
2701 cx,
2702 )
2703 .await;
2704
2705 let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| {
2706 let mut worktrees: Vec<_> = project.worktrees(cx).collect();
2707 worktrees.sort_by_key(|w| w.read(cx).abs_path());
2708 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
2709 });
2710
2711 let (multi_workspace, cx) =
2712 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2713 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2714 cx.run_until_parked();
2715
2716 // Select project A via the dropdown override and open the diff.
2717 workspace.update(cx, |workspace, cx| {
2718 workspace.set_active_worktree_override(Some(worktree_a_id), cx);
2719 });
2720 cx.focus(&workspace);
2721 cx.update(|window, cx| {
2722 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2723 });
2724 cx.run_until_parked();
2725
2726 let diff_item = workspace.update(cx, |workspace, cx| {
2727 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2728 });
2729 let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
2730 assert_eq!(paths_a.len(), 1);
2731 assert_eq!(*paths_a[0], *"a.txt");
2732
2733 // Switch the override to project B and re-run the diff action.
2734 workspace.update(cx, |workspace, cx| {
2735 workspace.set_active_worktree_override(Some(worktree_b_id), cx);
2736 });
2737 cx.focus(&workspace);
2738 cx.update(|window, cx| {
2739 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2740 });
2741 cx.run_until_parked();
2742
2743 let same_diff_item = workspace.update(cx, |workspace, cx| {
2744 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2745 });
2746 assert_eq!(diff_item.entity_id(), same_diff_item.entity_id());
2747
2748 let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
2749 assert_eq!(paths_b.len(), 1);
2750 assert_eq!(*paths_b[0], *"b.txt");
2751 }
2752}