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