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