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, ToggleFoldAll},
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::{BreadcrumbText, 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 is_collapsed: bool,
74 _task: Task<Result<()>>,
75 _subscription: Subscription,
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum RefreshReason {
80 DiffChanged,
81 StatusesChanged,
82 EditorSaved,
83}
84
85const CONFLICT_SORT_PREFIX: u64 = 1;
86const TRACKED_SORT_PREFIX: u64 = 2;
87const NEW_SORT_PREFIX: u64 = 3;
88
89impl ProjectDiff {
90 pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
91 workspace.register_action(Self::deploy);
92 workspace.register_action(Self::deploy_branch_diff);
93 workspace.register_action(|workspace, _: &Add, window, cx| {
94 Self::deploy(workspace, &Diff, window, cx);
95 });
96 workspace::register_serializable_item::<ProjectDiff>(cx);
97 }
98
99 fn deploy(
100 workspace: &mut Workspace,
101 _: &Diff,
102 window: &mut Window,
103 cx: &mut Context<Workspace>,
104 ) {
105 Self::deploy_at(workspace, None, window, cx)
106 }
107
108 fn deploy_branch_diff(
109 workspace: &mut Workspace,
110 _: &BranchDiff,
111 window: &mut Window,
112 cx: &mut Context<Workspace>,
113 ) {
114 telemetry::event!("Git Branch Diff Opened");
115 let project = workspace.project().clone();
116
117 let existing = workspace
118 .items_of_type::<Self>(cx)
119 .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }));
120 if let Some(existing) = existing {
121 workspace.activate_item(&existing, true, true, window, cx);
122 return;
123 }
124 let workspace = cx.entity();
125 window
126 .spawn(cx, async move |cx| {
127 let this = cx
128 .update(|window, cx| {
129 Self::new_with_default_branch(project, workspace.clone(), window, cx)
130 })?
131 .await?;
132 workspace
133 .update_in(cx, |workspace, window, cx| {
134 workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
135 })
136 .ok();
137 anyhow::Ok(())
138 })
139 .detach_and_notify_err(window, cx);
140 }
141
142 pub fn deploy_at(
143 workspace: &mut Workspace,
144 entry: Option<GitStatusEntry>,
145 window: &mut Window,
146 cx: &mut Context<Workspace>,
147 ) {
148 telemetry::event!(
149 "Git Diff Opened",
150 source = if entry.is_some() {
151 "Git Panel"
152 } else {
153 "Action"
154 }
155 );
156 let existing = workspace
157 .items_of_type::<Self>(cx)
158 .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
159 let project_diff = if let Some(existing) = existing {
160 existing.update(cx, |project_diff, cx| {
161 project_diff.move_to_beginning(window, cx);
162 });
163
164 workspace.activate_item(&existing, true, true, window, cx);
165 existing
166 } else {
167 let workspace_handle = cx.entity();
168 let project_diff =
169 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
170 workspace.add_item_to_active_pane(
171 Box::new(project_diff.clone()),
172 None,
173 true,
174 window,
175 cx,
176 );
177 project_diff
178 };
179 if let Some(entry) = entry {
180 project_diff.update(cx, |project_diff, cx| {
181 project_diff.move_to_entry(entry, window, cx);
182 })
183 }
184 }
185
186 pub fn autoscroll(&self, cx: &mut Context<Self>) {
187 self.editor.update(cx, |editor, cx| {
188 editor.primary_editor().update(cx, |editor, cx| {
189 editor.request_autoscroll(Autoscroll::fit(), cx);
190 })
191 })
192 }
193
194 fn new_with_default_branch(
195 project: Entity<Project>,
196 workspace: Entity<Workspace>,
197 window: &mut Window,
198 cx: &mut App,
199 ) -> Task<Result<Entity<Self>>> {
200 let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
201 return Task::ready(Err(anyhow!("No active repository")));
202 };
203 let main_branch = repo.update(cx, |repo, _| repo.default_branch());
204 window.spawn(cx, async move |cx| {
205 let main_branch = main_branch
206 .await??
207 .context("Could not determine default branch")?;
208
209 let branch_diff = cx.new_window_entity(|window, cx| {
210 branch_diff::BranchDiff::new(
211 DiffBase::Merge {
212 base_ref: main_branch,
213 },
214 project.clone(),
215 window,
216 cx,
217 )
218 })?;
219 cx.new_window_entity(|window, cx| {
220 Self::new_impl(branch_diff, project, workspace, window, cx)
221 })
222 })
223 }
224
225 fn new(
226 project: Entity<Project>,
227 workspace: Entity<Workspace>,
228 window: &mut Window,
229 cx: &mut Context<Self>,
230 ) -> Self {
231 let branch_diff =
232 cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx));
233 Self::new_impl(branch_diff, project, workspace, window, cx)
234 }
235
236 fn new_impl(
237 branch_diff: Entity<branch_diff::BranchDiff>,
238 project: Entity<Project>,
239 workspace: Entity<Workspace>,
240 window: &mut Window,
241 cx: &mut Context<Self>,
242 ) -> Self {
243 let focus_handle = cx.focus_handle();
244 let multibuffer = cx.new(|cx| {
245 let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
246 multibuffer.set_all_diff_hunks_expanded(cx);
247 multibuffer
248 });
249
250 let editor = cx.new(|cx| {
251 let diff_display_editor = SplittableEditor::new_unsplit(
252 multibuffer.clone(),
253 project.clone(),
254 workspace.clone(),
255 window,
256 cx,
257 );
258 diff_display_editor
259 .primary_editor()
260 .update(cx, |editor, cx| {
261 editor.disable_diagnostics(cx);
262
263 match branch_diff.read(cx).diff_base() {
264 DiffBase::Head => {
265 editor.register_addon(GitPanelAddon {
266 workspace: workspace.downgrade(),
267 });
268 }
269 DiffBase::Merge { .. } => {
270 editor.register_addon(BranchDiffAddon {
271 branch_diff: branch_diff.clone(),
272 });
273 editor.start_temporary_diff_override();
274 editor.set_render_diff_hunk_controls(
275 Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
276 cx,
277 );
278 }
279 }
280 });
281 diff_display_editor
282 });
283 cx.subscribe_in(&editor, window, Self::handle_editor_event)
284 .detach();
285
286 let branch_diff_subscription = cx.subscribe_in(
287 &branch_diff,
288 window,
289 move |this, _git_store, event, window, cx| match event {
290 BranchDiffEvent::FileListChanged => {
291 this._task = window.spawn(cx, {
292 let this = cx.weak_entity();
293 async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
294 })
295 }
296 },
297 );
298
299 let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
300 let mut was_collapse_untracked_diff =
301 GitPanelSettings::get_global(cx).collapse_untracked_diff;
302 cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
303 let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
304 let is_collapse_untracked_diff =
305 GitPanelSettings::get_global(cx).collapse_untracked_diff;
306 if is_sort_by_path != was_sort_by_path
307 || is_collapse_untracked_diff != was_collapse_untracked_diff
308 {
309 this._task = {
310 window.spawn(cx, {
311 let this = cx.weak_entity();
312 async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
313 })
314 }
315 }
316 was_sort_by_path = is_sort_by_path;
317 was_collapse_untracked_diff = is_collapse_untracked_diff;
318 })
319 .detach();
320
321 let task = window.spawn(cx, {
322 let this = cx.weak_entity();
323 async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
324 });
325
326 Self {
327 project,
328 workspace: workspace.downgrade(),
329 branch_diff,
330 focus_handle,
331 editor,
332 multibuffer,
333 is_collapsed: false,
334 buffer_diff_subscriptions: Default::default(),
335 pending_scroll: None,
336 _task: task,
337 _subscription: branch_diff_subscription,
338 }
339 }
340
341 pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
342 self.branch_diff.read(cx).diff_base()
343 }
344
345 pub fn move_to_entry(
346 &mut self,
347 entry: GitStatusEntry,
348 window: &mut Window,
349 cx: &mut Context<Self>,
350 ) {
351 let Some(git_repo) = self.branch_diff.read(cx).repo() else {
352 return;
353 };
354 let repo = git_repo.read(cx);
355 let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx);
356 let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
357
358 self.move_to_path(path_key, window, cx)
359 }
360
361 pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
362 let editor = self.editor.read(cx).last_selected_editor().read(cx);
363 let position = editor.selections.newest_anchor().head();
364 let multi_buffer = editor.buffer().read(cx);
365 let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
366
367 let file = buffer.read(cx).file()?;
368 Some(ProjectPath {
369 worktree_id: file.worktree_id(cx),
370 path: file.path().clone(),
371 })
372 }
373
374 fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
375 self.editor.update(cx, |editor, cx| {
376 editor.primary_editor().update(cx, |editor, cx| {
377 editor.move_to_beginning(&Default::default(), window, cx);
378 });
379 });
380 }
381
382 fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
383 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
384 self.editor.update(cx, |editor, cx| {
385 editor.primary_editor().update(cx, |editor, cx| {
386 editor.change_selections(
387 SelectionEffects::scroll(Autoscroll::focused()),
388 window,
389 cx,
390 |s| {
391 s.select_ranges([position..position]);
392 },
393 )
394 })
395 });
396 } else {
397 self.pending_scroll = Some(path_key);
398 }
399 }
400
401 fn button_states(&self, cx: &App) -> ButtonStates {
402 let editor = self.editor.read(cx).primary_editor().read(cx);
403 let snapshot = self.multibuffer.read(cx).snapshot(cx);
404 let prev_next = snapshot.diff_hunks().nth(1).is_some();
405 let mut selection = true;
406
407 let mut ranges = editor
408 .selections
409 .disjoint_anchor_ranges()
410 .collect::<Vec<_>>();
411 if !ranges.iter().any(|range| range.start != range.end) {
412 selection = false;
413 if let Some((excerpt_id, _, range)) = self
414 .editor
415 .read(cx)
416 .primary_editor()
417 .read(cx)
418 .active_excerpt(cx)
419 {
420 ranges = vec![multi_buffer::Anchor::range_in_buffer(excerpt_id, range)];
421 } else {
422 ranges = Vec::default();
423 }
424 }
425 let mut has_staged_hunks = false;
426 let mut has_unstaged_hunks = false;
427 for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
428 match hunk.secondary_status {
429 DiffHunkSecondaryStatus::HasSecondaryHunk
430 | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
431 has_unstaged_hunks = true;
432 }
433 DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
434 has_staged_hunks = true;
435 has_unstaged_hunks = true;
436 }
437 DiffHunkSecondaryStatus::NoSecondaryHunk
438 | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
439 has_staged_hunks = true;
440 }
441 }
442 }
443 let mut stage_all = false;
444 let mut unstage_all = false;
445 self.workspace
446 .read_with(cx, |workspace, cx| {
447 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
448 let git_panel = git_panel.read(cx);
449 stage_all = git_panel.can_stage_all();
450 unstage_all = git_panel.can_unstage_all();
451 }
452 })
453 .ok();
454
455 ButtonStates {
456 stage: has_unstaged_hunks,
457 unstage: has_staged_hunks,
458 prev_next,
459 selection,
460 stage_all,
461 unstage_all,
462 }
463 }
464
465 fn handle_editor_event(
466 &mut self,
467 editor: &Entity<SplittableEditor>,
468 event: &EditorEvent,
469 window: &mut Window,
470 cx: &mut Context<Self>,
471 ) {
472 match event {
473 EditorEvent::SelectionsChanged { local: true } => {
474 let Some(project_path) = self.active_path(cx) else {
475 return;
476 };
477 self.workspace
478 .update(cx, |workspace, cx| {
479 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
480 git_panel.update(cx, |git_panel, cx| {
481 git_panel.select_entry_by_path(project_path, window, cx)
482 })
483 }
484 })
485 .ok();
486 }
487 EditorEvent::Saved => {
488 self._task = cx.spawn_in(window, async move |this, cx| {
489 Self::refresh(this, RefreshReason::EditorSaved, cx).await
490 });
491 }
492 _ => {}
493 }
494 if editor.focus_handle(cx).contains_focused(window, cx)
495 && self.multibuffer.read(cx).is_empty()
496 {
497 self.focus_handle.focus(window)
498 }
499 }
500
501 #[instrument(skip_all)]
502 fn register_buffer(
503 &mut self,
504 path_key: PathKey,
505 file_status: FileStatus,
506 buffer: Entity<Buffer>,
507 diff: Entity<BufferDiff>,
508 window: &mut Window,
509 cx: &mut Context<Self>,
510 ) {
511 let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
512 this._task = window.spawn(cx, {
513 let this = cx.weak_entity();
514 async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await
515 })
516 });
517 self.buffer_diff_subscriptions
518 .insert(path_key.path.clone(), (diff.clone(), subscription));
519
520 // TODO(split-diff) we shouldn't have a conflict addon when split
521 let conflict_addon = self
522 .editor
523 .read(cx)
524 .primary_editor()
525 .read(cx)
526 .addon::<ConflictAddon>()
527 .expect("project diff editor should have a conflict addon");
528
529 let snapshot = buffer.read(cx).snapshot();
530 let diff_read = diff.read(cx);
531
532 let excerpt_ranges = {
533 let diff_hunk_ranges = diff_read
534 .hunks_intersecting_range(
535 Anchor::min_max_range_for_buffer(diff_read.buffer_id),
536 &snapshot,
537 cx,
538 )
539 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
540 let conflicts = conflict_addon
541 .conflict_set(snapshot.remote_id())
542 .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts)
543 .unwrap_or_default();
544 let mut conflicts = conflicts
545 .iter()
546 .map(|conflict| conflict.range.to_point(&snapshot))
547 .peekable();
548
549 if conflicts.peek().is_some() {
550 conflicts.collect::<Vec<_>>()
551 } else {
552 diff_hunk_ranges.collect()
553 }
554 };
555
556 let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| {
557 let was_empty = multibuffer.is_empty();
558 let (_, is_newly_added) = multibuffer.set_excerpts_for_path(
559 path_key.clone(),
560 buffer,
561 excerpt_ranges,
562 multibuffer_context_lines(cx),
563 cx,
564 );
565 if self.branch_diff.read(cx).diff_base().is_merge_base() {
566 multibuffer.add_diff(diff.clone(), cx);
567 }
568 (was_empty, is_newly_added)
569 });
570
571 self.editor.update(cx, |editor, cx| {
572 editor.primary_editor().update(cx, |editor, cx| {
573 if was_empty {
574 editor.change_selections(
575 SelectionEffects::no_scroll(),
576 window,
577 cx,
578 |selections| {
579 selections.select_ranges([
580 multi_buffer::Anchor::min()..multi_buffer::Anchor::min()
581 ])
582 },
583 );
584 }
585 if is_excerpt_newly_added
586 && (file_status.is_deleted()
587 || (file_status.is_untracked()
588 && GitPanelSettings::get_global(cx).collapse_untracked_diff))
589 {
590 editor.fold_buffer(snapshot.text.remote_id(), cx)
591 }
592 })
593 });
594
595 if self.multibuffer.read(cx).is_empty()
596 && self
597 .editor
598 .read(cx)
599 .focus_handle(cx)
600 .contains_focused(window, cx)
601 {
602 self.focus_handle.focus(window);
603 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
604 self.editor.update(cx, |editor, cx| {
605 editor.focus_handle(cx).focus(window);
606 });
607 }
608 if self.pending_scroll.as_ref() == Some(&path_key) {
609 self.move_to_path(path_key, window, cx);
610 }
611 }
612
613 pub async fn refresh(
614 this: WeakEntity<Self>,
615 reason: RefreshReason,
616 cx: &mut AsyncWindowContext,
617 ) -> Result<()> {
618 let mut path_keys = Vec::new();
619 let buffers_to_load = this.update(cx, |this, cx| {
620 let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
621 let load_buffers = branch_diff.load_buffers(cx);
622 (branch_diff.repo().cloned(), load_buffers)
623 });
624 let mut previous_paths = this
625 .multibuffer
626 .read(cx)
627 .paths()
628 .cloned()
629 .collect::<HashSet<_>>();
630
631 if let Some(repo) = repo {
632 let repo = repo.read(cx);
633
634 path_keys = Vec::with_capacity(buffers_to_load.len());
635 for entry in buffers_to_load.iter() {
636 let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx);
637 let path_key =
638 PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
639 previous_paths.remove(&path_key);
640 path_keys.push(path_key)
641 }
642 }
643
644 this.multibuffer.update(cx, |multibuffer, cx| {
645 for path in previous_paths {
646 if let Some(buffer) = multibuffer.buffer_for_path(&path, cx) {
647 let skip = match reason {
648 RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
649 buffer.read(cx).is_dirty()
650 }
651 RefreshReason::StatusesChanged => false,
652 };
653 if skip {
654 continue;
655 }
656 }
657
658 this.buffer_diff_subscriptions.remove(&path.path);
659 multibuffer.remove_excerpts_for_path(path.clone(), cx);
660 }
661 });
662 buffers_to_load
663 })?;
664
665 for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) {
666 if let Some((buffer, diff)) = entry.load.await.log_err() {
667 // We might be lagging behind enough that all future entry.load futures are no longer pending.
668 // If that is the case, this task will never yield, starving the foreground thread of execution time.
669 yield_now().await;
670 cx.update(|window, cx| {
671 this.update(cx, |this, cx| {
672 let multibuffer = this.multibuffer.read(cx);
673 let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some()
674 && multibuffer
675 .diff_for(buffer.read(cx).remote_id())
676 .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id())
677 && match reason {
678 RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
679 buffer.read(cx).is_dirty()
680 }
681 RefreshReason::StatusesChanged => false,
682 };
683 if !skip {
684 this.register_buffer(
685 path_key,
686 entry.file_status,
687 buffer,
688 diff,
689 window,
690 cx,
691 )
692 }
693 })
694 .ok();
695 })?;
696 }
697 }
698 this.update(cx, |this, cx| {
699 this.pending_scroll.take();
700 cx.notify();
701 })?;
702
703 Ok(())
704 }
705
706 #[cfg(any(test, feature = "test-support"))]
707 pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
708 self.multibuffer
709 .read(cx)
710 .paths()
711 .map(|key| key.path.clone())
712 .collect()
713 }
714}
715
716fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
717 let settings = GitPanelSettings::get_global(cx);
718
719 // Tree view can only sort by path
720 if settings.sort_by_path || settings.tree_view {
721 TRACKED_SORT_PREFIX
722 } else if repo.had_conflict_on_last_merge_head_change(repo_path) {
723 CONFLICT_SORT_PREFIX
724 } else if status.is_created() {
725 NEW_SORT_PREFIX
726 } else {
727 TRACKED_SORT_PREFIX
728 }
729}
730
731impl EventEmitter<EditorEvent> for ProjectDiff {}
732
733impl Focusable for ProjectDiff {
734 fn focus_handle(&self, cx: &App) -> FocusHandle {
735 if self.multibuffer.read(cx).is_empty() {
736 self.focus_handle.clone()
737 } else {
738 self.editor.focus_handle(cx)
739 }
740 }
741}
742
743impl Item for ProjectDiff {
744 type Event = EditorEvent;
745
746 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
747 Some(Icon::new(IconName::GitBranch).color(Color::Muted))
748 }
749
750 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
751 Editor::to_item_events(event, f)
752 }
753
754 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
755 self.editor.update(cx, |editor, cx| {
756 editor.primary_editor().update(cx, |primary_editor, cx| {
757 primary_editor.deactivated(window, cx);
758 })
759 });
760 }
761
762 fn navigate(
763 &mut self,
764 data: Box<dyn Any>,
765 window: &mut Window,
766 cx: &mut Context<Self>,
767 ) -> bool {
768 self.editor.update(cx, |editor, cx| {
769 editor.primary_editor().update(cx, |primary_editor, cx| {
770 primary_editor.navigate(data, window, cx)
771 })
772 })
773 }
774
775 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
776 Some("Project Diff".into())
777 }
778
779 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
780 Label::new(self.tab_content_text(0, cx))
781 .color(if params.selected {
782 Color::Default
783 } else {
784 Color::Muted
785 })
786 .into_any_element()
787 }
788
789 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
790 match self.branch_diff.read(cx).diff_base() {
791 DiffBase::Head => "Uncommitted Changes".into(),
792 DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
793 }
794 }
795
796 fn telemetry_event_text(&self) -> Option<&'static str> {
797 Some("Project Diff Opened")
798 }
799
800 fn as_searchable(&self, _: &Entity<Self>, cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
801 // TODO(split-diff) SplitEditor should be searchable
802 Some(Box::new(self.editor.read(cx).primary_editor().clone()))
803 }
804
805 fn for_each_project_item(
806 &self,
807 cx: &App,
808 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
809 ) {
810 self.editor
811 .read(cx)
812 .primary_editor()
813 .read(cx)
814 .for_each_project_item(cx, f)
815 }
816
817 fn set_nav_history(
818 &mut self,
819 nav_history: ItemNavHistory,
820 _: &mut Window,
821 cx: &mut Context<Self>,
822 ) {
823 self.editor.update(cx, |editor, cx| {
824 editor.primary_editor().update(cx, |primary_editor, _| {
825 primary_editor.set_nav_history(Some(nav_history));
826 })
827 });
828 }
829
830 fn can_split(&self) -> bool {
831 true
832 }
833
834 fn clone_on_split(
835 &self,
836 _workspace_id: Option<workspace::WorkspaceId>,
837 window: &mut Window,
838 cx: &mut Context<Self>,
839 ) -> Task<Option<Entity<Self>>>
840 where
841 Self: Sized,
842 {
843 let Some(workspace) = self.workspace.upgrade() else {
844 return Task::ready(None);
845 };
846 Task::ready(Some(cx.new(|cx| {
847 ProjectDiff::new(self.project.clone(), workspace, window, cx)
848 })))
849 }
850
851 fn is_dirty(&self, cx: &App) -> bool {
852 self.multibuffer.read(cx).is_dirty(cx)
853 }
854
855 fn has_conflict(&self, cx: &App) -> bool {
856 self.multibuffer.read(cx).has_conflict(cx)
857 }
858
859 fn can_save(&self, _: &App) -> bool {
860 true
861 }
862
863 fn save(
864 &mut self,
865 options: SaveOptions,
866 project: Entity<Project>,
867 window: &mut Window,
868 cx: &mut Context<Self>,
869 ) -> Task<Result<()>> {
870 self.editor.update(cx, |editor, cx| {
871 editor.primary_editor().update(cx, |primary_editor, cx| {
872 primary_editor.save(options, project, window, cx)
873 })
874 })
875 }
876
877 fn save_as(
878 &mut self,
879 _: Entity<Project>,
880 _: ProjectPath,
881 _window: &mut Window,
882 _: &mut Context<Self>,
883 ) -> Task<Result<()>> {
884 unreachable!()
885 }
886
887 fn reload(
888 &mut self,
889 project: Entity<Project>,
890 window: &mut Window,
891 cx: &mut Context<Self>,
892 ) -> Task<Result<()>> {
893 self.editor.update(cx, |editor, cx| {
894 editor.primary_editor().update(cx, |primary_editor, cx| {
895 primary_editor.reload(project, window, cx)
896 })
897 })
898 }
899
900 fn act_as_type<'a>(
901 &'a self,
902 type_id: TypeId,
903 self_handle: &'a Entity<Self>,
904 cx: &'a App,
905 ) -> Option<gpui::AnyEntity> {
906 if type_id == TypeId::of::<Self>() {
907 Some(self_handle.clone().into())
908 } else if type_id == TypeId::of::<Editor>() {
909 Some(self.editor.read(cx).primary_editor().clone().into())
910 } else {
911 None
912 }
913 }
914
915 fn breadcrumb_prefix(
916 &self,
917 _window: &mut Window,
918 cx: &mut Context<Self>,
919 ) -> Option<gpui::AnyElement> {
920 let is_collapsed = self.is_collapsed;
921
922 let (icon, label, tooltip_label) = if is_collapsed {
923 (
924 IconName::ChevronUpDown,
925 "Expand All",
926 "Expand All Search Results",
927 )
928 } else {
929 (
930 IconName::ChevronDownUp,
931 "Collapse All",
932 "Collapse All Search Results",
933 )
934 };
935
936 let focus_handle = self.editor.focus_handle(cx);
937
938 Some(
939 Button::new("multibuffer-collapse-expand", label)
940 .icon(icon)
941 .icon_position(IconPosition::Start)
942 .icon_size(IconSize::Small)
943 .tooltip(move |_, cx| {
944 Tooltip::for_action_in(tooltip_label, &ToggleFoldAll, &focus_handle, cx)
945 })
946 .on_click(cx.listener(|this, _, window, cx| {
947 this.is_collapsed = !this.is_collapsed;
948 this.editor.update(cx, |splittable, cx| {
949 splittable.last_selected_editor().update(cx, |editor, cx| {
950 editor.toggle_fold_all(&ToggleFoldAll, window, cx);
951 })
952 })
953 }))
954 .into_any_element(),
955 )
956 }
957
958 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
959 ToolbarItemLocation::Hidden
960 }
961
962 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
963 self.editor
964 .read(cx)
965 .last_selected_editor()
966 .read(cx)
967 .breadcrumbs(theme, cx)
968 }
969
970 fn added_to_workspace(
971 &mut self,
972 workspace: &mut Workspace,
973 window: &mut Window,
974 cx: &mut Context<Self>,
975 ) {
976 self.editor.update(cx, |editor, cx| {
977 editor.added_to_workspace(workspace, window, cx)
978 });
979 }
980}
981
982impl Render for ProjectDiff {
983 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
984 let is_empty = self.multibuffer.read(cx).is_empty();
985
986 div()
987 .track_focus(&self.focus_handle)
988 .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
989 .bg(cx.theme().colors().editor_background)
990 .flex()
991 .items_center()
992 .justify_center()
993 .size_full()
994 .when(is_empty, |el| {
995 let remote_button = if let Some(panel) = self
996 .workspace
997 .upgrade()
998 .and_then(|workspace| workspace.read(cx).panel::<GitPanel>(cx))
999 {
1000 panel.update(cx, |panel, cx| panel.render_remote_button(cx))
1001 } else {
1002 None
1003 };
1004 let keybinding_focus_handle = self.focus_handle(cx);
1005 el.child(
1006 v_flex()
1007 .gap_1()
1008 .child(
1009 h_flex()
1010 .justify_around()
1011 .child(Label::new("No uncommitted changes")),
1012 )
1013 .map(|el| match remote_button {
1014 Some(button) => el.child(h_flex().justify_around().child(button)),
1015 None => el.child(
1016 h_flex()
1017 .justify_around()
1018 .child(Label::new("Remote up to date")),
1019 ),
1020 })
1021 .child(
1022 h_flex().justify_around().mt_1().child(
1023 Button::new("project-diff-close-button", "Close")
1024 // .style(ButtonStyle::Transparent)
1025 .key_binding(KeyBinding::for_action_in(
1026 &CloseActiveItem::default(),
1027 &keybinding_focus_handle,
1028 cx,
1029 ))
1030 .on_click(move |_, window, cx| {
1031 window.focus(&keybinding_focus_handle);
1032 window.dispatch_action(
1033 Box::new(CloseActiveItem::default()),
1034 cx,
1035 );
1036 }),
1037 ),
1038 ),
1039 )
1040 })
1041 .when(!is_empty, |el| el.child(self.editor.clone()))
1042 }
1043}
1044
1045impl SerializableItem for ProjectDiff {
1046 fn serialized_item_kind() -> &'static str {
1047 "ProjectDiff"
1048 }
1049
1050 fn cleanup(
1051 _: workspace::WorkspaceId,
1052 _: Vec<workspace::ItemId>,
1053 _: &mut Window,
1054 _: &mut App,
1055 ) -> Task<Result<()>> {
1056 Task::ready(Ok(()))
1057 }
1058
1059 fn deserialize(
1060 project: Entity<Project>,
1061 workspace: WeakEntity<Workspace>,
1062 workspace_id: workspace::WorkspaceId,
1063 item_id: workspace::ItemId,
1064 window: &mut Window,
1065 cx: &mut App,
1066 ) -> Task<Result<Entity<Self>>> {
1067 window.spawn(cx, async move |cx| {
1068 let diff_base = persistence::PROJECT_DIFF_DB.get_diff_base(item_id, workspace_id)?;
1069
1070 let diff = cx.update(|window, cx| {
1071 let branch_diff = cx
1072 .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx));
1073 let workspace = workspace.upgrade().context("workspace gone")?;
1074 anyhow::Ok(
1075 cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
1076 )
1077 })??;
1078
1079 Ok(diff)
1080 })
1081 }
1082
1083 fn serialize(
1084 &mut self,
1085 workspace: &mut Workspace,
1086 item_id: workspace::ItemId,
1087 _closing: bool,
1088 _window: &mut Window,
1089 cx: &mut Context<Self>,
1090 ) -> Option<Task<Result<()>>> {
1091 let workspace_id = workspace.database_id()?;
1092 let diff_base = self.diff_base(cx).clone();
1093
1094 Some(cx.background_spawn({
1095 async move {
1096 persistence::PROJECT_DIFF_DB
1097 .save_diff_base(item_id, workspace_id, diff_base.clone())
1098 .await
1099 }
1100 }))
1101 }
1102
1103 fn should_serialize(&self, _: &Self::Event) -> bool {
1104 false
1105 }
1106}
1107
1108mod persistence {
1109
1110 use anyhow::Context as _;
1111 use db::{
1112 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
1113 sqlez_macros::sql,
1114 };
1115 use project::git_store::branch_diff::DiffBase;
1116 use workspace::{ItemId, WorkspaceDb, WorkspaceId};
1117
1118 pub struct ProjectDiffDb(ThreadSafeConnection);
1119
1120 impl Domain for ProjectDiffDb {
1121 const NAME: &str = stringify!(ProjectDiffDb);
1122
1123 const MIGRATIONS: &[&str] = &[sql!(
1124 CREATE TABLE project_diffs(
1125 workspace_id INTEGER,
1126 item_id INTEGER UNIQUE,
1127
1128 diff_base TEXT,
1129
1130 PRIMARY KEY(workspace_id, item_id),
1131 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
1132 ON DELETE CASCADE
1133 ) STRICT;
1134 )];
1135 }
1136
1137 db::static_connection!(PROJECT_DIFF_DB, ProjectDiffDb, [WorkspaceDb]);
1138
1139 impl ProjectDiffDb {
1140 pub async fn save_diff_base(
1141 &self,
1142 item_id: ItemId,
1143 workspace_id: WorkspaceId,
1144 diff_base: DiffBase,
1145 ) -> anyhow::Result<()> {
1146 self.write(move |connection| {
1147 let sql_stmt = sql!(
1148 INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?)
1149 );
1150 let diff_base_str = serde_json::to_string(&diff_base)?;
1151 let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?;
1152 query((item_id, workspace_id, diff_base_str)).context(format!(
1153 "exec_bound failed to execute or parse for: {}",
1154 sql_stmt
1155 ))
1156 })
1157 .await
1158 }
1159
1160 pub fn get_diff_base(
1161 &self,
1162 item_id: ItemId,
1163 workspace_id: WorkspaceId,
1164 ) -> anyhow::Result<DiffBase> {
1165 let sql_stmt =
1166 sql!(SELECT diff_base FROM project_diffs WHERE item_id = ?AND workspace_id = ?);
1167 let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?(
1168 (item_id, workspace_id),
1169 )
1170 .context(::std::format!(
1171 "Error in get_diff_base, select_row_bound failed to execute or parse for: {}",
1172 sql_stmt
1173 ))?;
1174 let Some(diff_base_str) = diff_base_str else {
1175 return Ok(DiffBase::Head);
1176 };
1177 serde_json::from_str(&diff_base_str).context("deserializing diff base")
1178 }
1179 }
1180}
1181
1182pub struct ProjectDiffToolbar {
1183 project_diff: Option<WeakEntity<ProjectDiff>>,
1184 workspace: WeakEntity<Workspace>,
1185}
1186
1187impl ProjectDiffToolbar {
1188 pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
1189 Self {
1190 project_diff: None,
1191 workspace: workspace.weak_handle(),
1192 }
1193 }
1194
1195 fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
1196 self.project_diff.as_ref()?.upgrade()
1197 }
1198
1199 fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
1200 if let Some(project_diff) = self.project_diff(cx) {
1201 project_diff.focus_handle(cx).focus(window);
1202 }
1203 let action = action.boxed_clone();
1204 cx.defer(move |cx| {
1205 cx.dispatch_action(action.as_ref());
1206 })
1207 }
1208
1209 fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1210 self.workspace
1211 .update(cx, |workspace, cx| {
1212 if let Some(panel) = workspace.panel::<GitPanel>(cx) {
1213 panel.update(cx, |panel, cx| {
1214 panel.stage_all(&Default::default(), window, cx);
1215 });
1216 }
1217 })
1218 .ok();
1219 }
1220
1221 fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1222 self.workspace
1223 .update(cx, |workspace, cx| {
1224 let Some(panel) = workspace.panel::<GitPanel>(cx) else {
1225 return;
1226 };
1227 panel.update(cx, |panel, cx| {
1228 panel.unstage_all(&Default::default(), window, cx);
1229 });
1230 })
1231 .ok();
1232 }
1233}
1234
1235impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
1236
1237impl ToolbarItemView for ProjectDiffToolbar {
1238 fn set_active_pane_item(
1239 &mut self,
1240 active_pane_item: Option<&dyn ItemHandle>,
1241 _: &mut Window,
1242 cx: &mut Context<Self>,
1243 ) -> ToolbarItemLocation {
1244 self.project_diff = active_pane_item
1245 .and_then(|item| item.act_as::<ProjectDiff>(cx))
1246 .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head)
1247 .map(|entity| entity.downgrade());
1248 if self.project_diff.is_some() {
1249 ToolbarItemLocation::PrimaryRight
1250 } else {
1251 ToolbarItemLocation::Hidden
1252 }
1253 }
1254
1255 fn pane_focus_update(
1256 &mut self,
1257 _pane_focused: bool,
1258 _window: &mut Window,
1259 _cx: &mut Context<Self>,
1260 ) {
1261 }
1262}
1263
1264struct ButtonStates {
1265 stage: bool,
1266 unstage: bool,
1267 prev_next: bool,
1268 selection: bool,
1269 stage_all: bool,
1270 unstage_all: bool,
1271}
1272
1273impl Render for ProjectDiffToolbar {
1274 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1275 let Some(project_diff) = self.project_diff(cx) else {
1276 return div();
1277 };
1278 let focus_handle = project_diff.focus_handle(cx);
1279 let button_states = project_diff.read(cx).button_states(cx);
1280
1281 h_group_xl()
1282 .my_neg_1()
1283 .py_1()
1284 .items_center()
1285 .flex_wrap()
1286 .justify_between()
1287 .child(
1288 h_group_sm()
1289 .when(button_states.selection, |el| {
1290 el.child(
1291 Button::new("stage", "Toggle Staged")
1292 .tooltip(Tooltip::for_action_title_in(
1293 "Toggle Staged",
1294 &ToggleStaged,
1295 &focus_handle,
1296 ))
1297 .disabled(!button_states.stage && !button_states.unstage)
1298 .on_click(cx.listener(|this, _, window, cx| {
1299 this.dispatch_action(&ToggleStaged, window, cx)
1300 })),
1301 )
1302 })
1303 .when(!button_states.selection, |el| {
1304 el.child(
1305 Button::new("stage", "Stage")
1306 .tooltip(Tooltip::for_action_title_in(
1307 "Stage and go to next hunk",
1308 &StageAndNext,
1309 &focus_handle,
1310 ))
1311 .disabled(
1312 !button_states.prev_next
1313 && !button_states.stage_all
1314 && !button_states.unstage_all,
1315 )
1316 .on_click(cx.listener(|this, _, window, cx| {
1317 this.dispatch_action(&StageAndNext, window, cx)
1318 })),
1319 )
1320 .child(
1321 Button::new("unstage", "Unstage")
1322 .tooltip(Tooltip::for_action_title_in(
1323 "Unstage and go to next hunk",
1324 &UnstageAndNext,
1325 &focus_handle,
1326 ))
1327 .disabled(
1328 !button_states.prev_next
1329 && !button_states.stage_all
1330 && !button_states.unstage_all,
1331 )
1332 .on_click(cx.listener(|this, _, window, cx| {
1333 this.dispatch_action(&UnstageAndNext, window, cx)
1334 })),
1335 )
1336 }),
1337 )
1338 // n.b. the only reason these arrows are here is because we don't
1339 // support "undo" for staging so we need a way to go back.
1340 .child(
1341 h_group_sm()
1342 .child(
1343 IconButton::new("up", IconName::ArrowUp)
1344 .shape(ui::IconButtonShape::Square)
1345 .tooltip(Tooltip::for_action_title_in(
1346 "Go to previous hunk",
1347 &GoToPreviousHunk,
1348 &focus_handle,
1349 ))
1350 .disabled(!button_states.prev_next)
1351 .on_click(cx.listener(|this, _, window, cx| {
1352 this.dispatch_action(&GoToPreviousHunk, window, cx)
1353 })),
1354 )
1355 .child(
1356 IconButton::new("down", IconName::ArrowDown)
1357 .shape(ui::IconButtonShape::Square)
1358 .tooltip(Tooltip::for_action_title_in(
1359 "Go to next hunk",
1360 &GoToHunk,
1361 &focus_handle,
1362 ))
1363 .disabled(!button_states.prev_next)
1364 .on_click(cx.listener(|this, _, window, cx| {
1365 this.dispatch_action(&GoToHunk, window, cx)
1366 })),
1367 ),
1368 )
1369 .child(vertical_divider())
1370 .child(
1371 h_group_sm()
1372 .when(
1373 button_states.unstage_all && !button_states.stage_all,
1374 |el| {
1375 el.child(
1376 Button::new("unstage-all", "Unstage All")
1377 .tooltip(Tooltip::for_action_title_in(
1378 "Unstage all changes",
1379 &UnstageAll,
1380 &focus_handle,
1381 ))
1382 .on_click(cx.listener(|this, _, window, cx| {
1383 this.unstage_all(window, cx)
1384 })),
1385 )
1386 },
1387 )
1388 .when(
1389 !button_states.unstage_all || button_states.stage_all,
1390 |el| {
1391 el.child(
1392 // todo make it so that changing to say "Unstaged"
1393 // doesn't change the position.
1394 div().child(
1395 Button::new("stage-all", "Stage All")
1396 .disabled(!button_states.stage_all)
1397 .tooltip(Tooltip::for_action_title_in(
1398 "Stage all changes",
1399 &StageAll,
1400 &focus_handle,
1401 ))
1402 .on_click(cx.listener(|this, _, window, cx| {
1403 this.stage_all(window, cx)
1404 })),
1405 ),
1406 )
1407 },
1408 )
1409 .child(
1410 Button::new("commit", "Commit")
1411 .tooltip(Tooltip::for_action_title_in(
1412 "Commit",
1413 &Commit,
1414 &focus_handle,
1415 ))
1416 .on_click(cx.listener(|this, _, window, cx| {
1417 this.dispatch_action(&Commit, window, cx);
1418 })),
1419 ),
1420 )
1421 }
1422}
1423
1424#[derive(IntoElement, RegisterComponent)]
1425pub struct ProjectDiffEmptyState {
1426 pub no_repo: bool,
1427 pub can_push_and_pull: bool,
1428 pub focus_handle: Option<FocusHandle>,
1429 pub current_branch: Option<Branch>,
1430 // has_pending_commits: bool,
1431 // ahead_of_remote: bool,
1432 // no_git_repository: bool,
1433}
1434
1435impl RenderOnce for ProjectDiffEmptyState {
1436 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
1437 let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
1438 matches!(self.current_branch, Some(Branch {
1439 upstream:
1440 Some(Upstream {
1441 tracking:
1442 UpstreamTracking::Tracked(UpstreamTrackingStatus {
1443 ahead, behind, ..
1444 }),
1445 ..
1446 }),
1447 ..
1448 }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0))
1449 };
1450
1451 let change_count = |current_branch: &Branch| -> (usize, usize) {
1452 match current_branch {
1453 Branch {
1454 upstream:
1455 Some(Upstream {
1456 tracking:
1457 UpstreamTracking::Tracked(UpstreamTrackingStatus {
1458 ahead, behind, ..
1459 }),
1460 ..
1461 }),
1462 ..
1463 } => (*ahead as usize, *behind as usize),
1464 _ => (0, 0),
1465 }
1466 };
1467
1468 let not_ahead_or_behind = status_against_remote(0, 0);
1469 let ahead_of_remote = status_against_remote(1, 0);
1470 let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
1471 branch.upstream.is_none()
1472 } else {
1473 false
1474 };
1475
1476 let has_branch_container = |branch: &Branch| {
1477 h_flex()
1478 .max_w(px(420.))
1479 .bg(cx.theme().colors().text.opacity(0.05))
1480 .border_1()
1481 .border_color(cx.theme().colors().border)
1482 .rounded_sm()
1483 .gap_8()
1484 .px_6()
1485 .py_4()
1486 .map(|this| {
1487 if ahead_of_remote {
1488 let ahead_count = change_count(branch).0;
1489 let ahead_string = format!("{} Commits Ahead", ahead_count);
1490 this.child(
1491 v_flex()
1492 .child(Headline::new(ahead_string).size(HeadlineSize::Small))
1493 .child(
1494 Label::new(format!("Push your changes to {}", branch.name()))
1495 .color(Color::Muted),
1496 ),
1497 )
1498 .child(div().child(render_push_button(
1499 self.focus_handle,
1500 "push".into(),
1501 ahead_count as u32,
1502 )))
1503 } else if branch_not_on_remote {
1504 this.child(
1505 v_flex()
1506 .child(Headline::new("Publish Branch").size(HeadlineSize::Small))
1507 .child(
1508 Label::new(format!("Create {} on remote", branch.name()))
1509 .color(Color::Muted),
1510 ),
1511 )
1512 .child(
1513 div().child(render_publish_button(self.focus_handle, "publish".into())),
1514 )
1515 } else {
1516 this.child(Label::new("Remote status unknown").color(Color::Muted))
1517 }
1518 })
1519 };
1520
1521 v_flex().size_full().items_center().justify_center().child(
1522 v_flex()
1523 .gap_1()
1524 .when(self.no_repo, |this| {
1525 // TODO: add git init
1526 this.text_center()
1527 .child(Label::new("No Repository").color(Color::Muted))
1528 })
1529 .map(|this| {
1530 if not_ahead_or_behind && self.current_branch.is_some() {
1531 this.text_center()
1532 .child(Label::new("No Changes").color(Color::Muted))
1533 } else {
1534 this.when_some(self.current_branch.as_ref(), |this, branch| {
1535 this.child(has_branch_container(branch))
1536 })
1537 }
1538 }),
1539 )
1540 }
1541}
1542
1543mod preview {
1544 use git::repository::{
1545 Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
1546 };
1547 use ui::prelude::*;
1548
1549 use super::ProjectDiffEmptyState;
1550
1551 // View this component preview using `workspace: open component-preview`
1552 impl Component for ProjectDiffEmptyState {
1553 fn scope() -> ComponentScope {
1554 ComponentScope::VersionControl
1555 }
1556
1557 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
1558 let unknown_upstream: Option<UpstreamTracking> = None;
1559 let ahead_of_upstream: Option<UpstreamTracking> = Some(
1560 UpstreamTrackingStatus {
1561 ahead: 2,
1562 behind: 0,
1563 }
1564 .into(),
1565 );
1566
1567 let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
1568 UpstreamTrackingStatus {
1569 ahead: 0,
1570 behind: 0,
1571 }
1572 .into(),
1573 );
1574
1575 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
1576 Branch {
1577 is_head: true,
1578 ref_name: "some-branch".into(),
1579 upstream: upstream.map(|tracking| Upstream {
1580 ref_name: "origin/some-branch".into(),
1581 tracking,
1582 }),
1583 most_recent_commit: Some(CommitSummary {
1584 sha: "abc123".into(),
1585 subject: "Modify stuff".into(),
1586 commit_timestamp: 1710932954,
1587 author_name: "John Doe".into(),
1588 has_parent: true,
1589 }),
1590 }
1591 }
1592
1593 let no_repo_state = ProjectDiffEmptyState {
1594 no_repo: true,
1595 can_push_and_pull: false,
1596 focus_handle: None,
1597 current_branch: None,
1598 };
1599
1600 let no_changes_state = ProjectDiffEmptyState {
1601 no_repo: false,
1602 can_push_and_pull: true,
1603 focus_handle: None,
1604 current_branch: Some(branch(not_ahead_or_behind_upstream)),
1605 };
1606
1607 let ahead_of_upstream_state = ProjectDiffEmptyState {
1608 no_repo: false,
1609 can_push_and_pull: true,
1610 focus_handle: None,
1611 current_branch: Some(branch(ahead_of_upstream)),
1612 };
1613
1614 let unknown_upstream_state = ProjectDiffEmptyState {
1615 no_repo: false,
1616 can_push_and_pull: true,
1617 focus_handle: None,
1618 current_branch: Some(branch(unknown_upstream)),
1619 };
1620
1621 let (width, height) = (px(480.), px(320.));
1622
1623 Some(
1624 v_flex()
1625 .gap_6()
1626 .children(vec![
1627 example_group(vec![
1628 single_example(
1629 "No Repo",
1630 div()
1631 .w(width)
1632 .h(height)
1633 .child(no_repo_state)
1634 .into_any_element(),
1635 ),
1636 single_example(
1637 "No Changes",
1638 div()
1639 .w(width)
1640 .h(height)
1641 .child(no_changes_state)
1642 .into_any_element(),
1643 ),
1644 single_example(
1645 "Unknown Upstream",
1646 div()
1647 .w(width)
1648 .h(height)
1649 .child(unknown_upstream_state)
1650 .into_any_element(),
1651 ),
1652 single_example(
1653 "Ahead of Remote",
1654 div()
1655 .w(width)
1656 .h(height)
1657 .child(ahead_of_upstream_state)
1658 .into_any_element(),
1659 ),
1660 ])
1661 .vertical(),
1662 ])
1663 .into_any_element(),
1664 )
1665 }
1666 }
1667}
1668
1669struct BranchDiffAddon {
1670 branch_diff: Entity<branch_diff::BranchDiff>,
1671}
1672
1673impl Addon for BranchDiffAddon {
1674 fn to_any(&self) -> &dyn std::any::Any {
1675 self
1676 }
1677
1678 fn override_status_for_buffer_id(
1679 &self,
1680 buffer_id: language::BufferId,
1681 cx: &App,
1682 ) -> Option<FileStatus> {
1683 self.branch_diff
1684 .read(cx)
1685 .status_for_buffer_id(buffer_id, cx)
1686 }
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691 use collections::HashMap;
1692 use db::indoc;
1693 use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
1694 use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
1695 use gpui::TestAppContext;
1696 use project::FakeFs;
1697 use serde_json::json;
1698 use settings::SettingsStore;
1699 use std::path::Path;
1700 use unindent::Unindent as _;
1701 use util::{
1702 path,
1703 rel_path::{RelPath, rel_path},
1704 };
1705
1706 use super::*;
1707
1708 #[ctor::ctor]
1709 fn init_logger() {
1710 zlog::init_test();
1711 }
1712
1713 fn init_test(cx: &mut TestAppContext) {
1714 cx.update(|cx| {
1715 let store = SettingsStore::test(cx);
1716 cx.set_global(store);
1717 theme::init(theme::LoadThemes::JustBase, cx);
1718 editor::init(cx);
1719 crate::init(cx);
1720 });
1721 }
1722
1723 #[gpui::test]
1724 async fn test_save_after_restore(cx: &mut TestAppContext) {
1725 init_test(cx);
1726
1727 let fs = FakeFs::new(cx.executor());
1728 fs.insert_tree(
1729 path!("/project"),
1730 json!({
1731 ".git": {},
1732 "foo.txt": "FOO\n",
1733 }),
1734 )
1735 .await;
1736 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1737 let (workspace, cx) =
1738 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1739 let diff = cx.new_window_entity(|window, cx| {
1740 ProjectDiff::new(project.clone(), workspace, window, cx)
1741 });
1742 cx.run_until_parked();
1743
1744 fs.set_head_for_repo(
1745 path!("/project/.git").as_ref(),
1746 &[("foo.txt", "foo\n".into())],
1747 "deadbeef",
1748 );
1749 fs.set_index_for_repo(
1750 path!("/project/.git").as_ref(),
1751 &[("foo.txt", "foo\n".into())],
1752 );
1753 cx.run_until_parked();
1754
1755 let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone());
1756 assert_state_with_diff(
1757 &editor,
1758 cx,
1759 &"
1760 - foo
1761 + ˇFOO
1762 "
1763 .unindent(),
1764 );
1765
1766 editor
1767 .update_in(cx, |editor, window, cx| {
1768 editor.git_restore(&Default::default(), window, cx);
1769 editor.save(SaveOptions::default(), project.clone(), window, cx)
1770 })
1771 .await
1772 .unwrap();
1773 cx.run_until_parked();
1774
1775 assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1776
1777 let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1778 assert_eq!(text, "foo\n");
1779 }
1780
1781 #[gpui::test]
1782 async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1783 init_test(cx);
1784
1785 let fs = FakeFs::new(cx.executor());
1786 fs.insert_tree(
1787 path!("/project"),
1788 json!({
1789 ".git": {},
1790 "bar": "BAR\n",
1791 "foo": "FOO\n",
1792 }),
1793 )
1794 .await;
1795 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1796 let (workspace, cx) =
1797 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1798 let diff = cx.new_window_entity(|window, cx| {
1799 ProjectDiff::new(project.clone(), workspace, window, cx)
1800 });
1801 cx.run_until_parked();
1802
1803 fs.set_head_and_index_for_repo(
1804 path!("/project/.git").as_ref(),
1805 &[("bar", "bar\n".into()), ("foo", "foo\n".into())],
1806 );
1807 cx.run_until_parked();
1808
1809 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1810 diff.move_to_path(
1811 PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()),
1812 window,
1813 cx,
1814 );
1815 diff.editor.read(cx).primary_editor().clone()
1816 });
1817 assert_state_with_diff(
1818 &editor,
1819 cx,
1820 &"
1821 - bar
1822 + BAR
1823
1824 - ˇfoo
1825 + FOO
1826 "
1827 .unindent(),
1828 );
1829
1830 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1831 diff.move_to_path(
1832 PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()),
1833 window,
1834 cx,
1835 );
1836 diff.editor.read(cx).primary_editor().clone()
1837 });
1838 assert_state_with_diff(
1839 &editor,
1840 cx,
1841 &"
1842 - ˇbar
1843 + BAR
1844
1845 - foo
1846 + FOO
1847 "
1848 .unindent(),
1849 );
1850 }
1851
1852 #[gpui::test]
1853 async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1854 init_test(cx);
1855
1856 let fs = FakeFs::new(cx.executor());
1857 fs.insert_tree(
1858 path!("/project"),
1859 json!({
1860 ".git": {},
1861 "foo": "modified\n",
1862 }),
1863 )
1864 .await;
1865 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1866 let (workspace, cx) =
1867 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1868 let buffer = project
1869 .update(cx, |project, cx| {
1870 project.open_local_buffer(path!("/project/foo"), cx)
1871 })
1872 .await
1873 .unwrap();
1874 let buffer_editor = cx.new_window_entity(|window, cx| {
1875 Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1876 });
1877 let diff = cx.new_window_entity(|window, cx| {
1878 ProjectDiff::new(project.clone(), workspace, window, cx)
1879 });
1880 cx.run_until_parked();
1881
1882 fs.set_head_for_repo(
1883 path!("/project/.git").as_ref(),
1884 &[("foo", "original\n".into())],
1885 "deadbeef",
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}