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