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