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