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