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