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