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::NoSecondaryHunk
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 editor: &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 if editor.focus_handle(cx).contains_focused(window, cx) {
334 if self.multibuffer.read(cx).is_empty() {
335 self.focus_handle.focus(window)
336 }
337 }
338 }
339
340 fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
341 let Some(repo) = self.git_store.read(cx).active_repository() else {
342 self.multibuffer.update(cx, |multibuffer, cx| {
343 multibuffer.clear(cx);
344 });
345 return vec![];
346 };
347
348 let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
349
350 let mut result = vec![];
351 repo.update(cx, |repo, cx| {
352 for entry in repo.status() {
353 if !entry.status.has_changes() {
354 continue;
355 }
356 let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
357 continue;
358 };
359 let namespace = if repo.has_conflict(&entry.repo_path) {
360 CONFLICT_NAMESPACE
361 } else if entry.status.is_created() {
362 NEW_NAMESPACE
363 } else {
364 TRACKED_NAMESPACE
365 };
366 let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
367
368 previous_paths.remove(&path_key);
369 let load_buffer = self
370 .project
371 .update(cx, |project, cx| project.open_buffer(project_path, cx));
372
373 let project = self.project.clone();
374 result.push(cx.spawn(|_, mut cx| async move {
375 let buffer = load_buffer.await?;
376 let changes = project
377 .update(&mut cx, |project, cx| {
378 project.open_uncommitted_diff(buffer.clone(), cx)
379 })?
380 .await?;
381 Ok(DiffBuffer {
382 path_key,
383 buffer,
384 diff: changes,
385 file_status: entry.status,
386 })
387 }));
388 }
389 });
390 self.multibuffer.update(cx, |multibuffer, cx| {
391 for path in previous_paths {
392 multibuffer.remove_excerpts_for_path(path, cx);
393 }
394 });
395 result
396 }
397
398 fn register_buffer(
399 &mut self,
400 diff_buffer: DiffBuffer,
401 window: &mut Window,
402 cx: &mut Context<Self>,
403 ) {
404 let path_key = diff_buffer.path_key;
405 let buffer = diff_buffer.buffer;
406 let diff = diff_buffer.diff;
407
408 let snapshot = buffer.read(cx).snapshot();
409 let diff = diff.read(cx);
410 let diff_hunk_ranges = diff
411 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
412 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
413 .collect::<Vec<_>>();
414
415 let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| {
416 let was_empty = multibuffer.is_empty();
417 let is_newly_added = multibuffer.set_excerpts_for_path(
418 path_key.clone(),
419 buffer,
420 diff_hunk_ranges,
421 editor::DEFAULT_MULTIBUFFER_CONTEXT,
422 cx,
423 );
424 (was_empty, is_newly_added)
425 });
426
427 self.editor.update(cx, |editor, cx| {
428 if was_empty {
429 editor.change_selections(None, window, cx, |selections| {
430 // TODO select the very beginning (possibly inside a deletion)
431 selections.select_ranges([0..0])
432 });
433 }
434 if is_excerpt_newly_added && diff_buffer.file_status.is_deleted() {
435 editor.fold_buffer(snapshot.text.remote_id(), cx)
436 }
437 });
438
439 if self.multibuffer.read(cx).is_empty()
440 && self
441 .editor
442 .read(cx)
443 .focus_handle(cx)
444 .contains_focused(window, cx)
445 {
446 self.focus_handle.focus(window);
447 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
448 self.editor.update(cx, |editor, cx| {
449 editor.focus_handle(cx).focus(window);
450 });
451 }
452 if self.pending_scroll.as_ref() == Some(&path_key) {
453 self.move_to_path(path_key, window, cx);
454 }
455 }
456
457 pub async fn handle_status_updates(
458 this: WeakEntity<Self>,
459 mut recv: postage::watch::Receiver<()>,
460 mut cx: AsyncWindowContext,
461 ) -> Result<()> {
462 while let Some(_) = recv.next().await {
463 this.update(&mut cx, |this, cx| {
464 let new_branch =
465 this.git_store
466 .read(cx)
467 .active_repository()
468 .and_then(|active_repository| {
469 active_repository.read(cx).current_branch().cloned()
470 });
471 if new_branch != this.current_branch {
472 this.current_branch = new_branch;
473 cx.notify();
474 }
475 })?;
476
477 let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
478 for buffer_to_load in buffers_to_load {
479 if let Some(buffer) = buffer_to_load.await.log_err() {
480 cx.update(|window, cx| {
481 this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
482 .ok();
483 })?;
484 }
485 }
486 this.update(&mut cx, |this, cx| {
487 this.pending_scroll.take();
488 cx.notify();
489 })?;
490 }
491
492 Ok(())
493 }
494
495 #[cfg(any(test, feature = "test-support"))]
496 pub fn excerpt_paths(&self, cx: &App) -> Vec<String> {
497 self.multibuffer
498 .read(cx)
499 .excerpt_paths()
500 .map(|key| key.path().to_string_lossy().to_string())
501 .collect()
502 }
503}
504
505impl EventEmitter<EditorEvent> for ProjectDiff {}
506
507impl Focusable for ProjectDiff {
508 fn focus_handle(&self, cx: &App) -> FocusHandle {
509 if self.multibuffer.read(cx).is_empty() {
510 self.focus_handle.clone()
511 } else {
512 self.editor.focus_handle(cx)
513 }
514 }
515}
516
517impl Item for ProjectDiff {
518 type Event = EditorEvent;
519
520 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
521 Some(Icon::new(IconName::GitBranch).color(Color::Muted))
522 }
523
524 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
525 Editor::to_item_events(event, f)
526 }
527
528 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
529 self.editor
530 .update(cx, |editor, cx| editor.deactivated(window, cx));
531 }
532
533 fn navigate(
534 &mut self,
535 data: Box<dyn Any>,
536 window: &mut Window,
537 cx: &mut Context<Self>,
538 ) -> bool {
539 self.editor
540 .update(cx, |editor, cx| editor.navigate(data, window, cx))
541 }
542
543 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
544 Some("Project Diff".into())
545 }
546
547 fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
548 Label::new("Uncommitted Changes")
549 .color(if params.selected {
550 Color::Default
551 } else {
552 Color::Muted
553 })
554 .into_any_element()
555 }
556
557 fn telemetry_event_text(&self) -> Option<&'static str> {
558 Some("Project Diff Opened")
559 }
560
561 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
562 Some(Box::new(self.editor.clone()))
563 }
564
565 fn for_each_project_item(
566 &self,
567 cx: &App,
568 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
569 ) {
570 self.editor.for_each_project_item(cx, f)
571 }
572
573 fn is_singleton(&self, _: &App) -> bool {
574 false
575 }
576
577 fn set_nav_history(
578 &mut self,
579 nav_history: ItemNavHistory,
580 _: &mut Window,
581 cx: &mut Context<Self>,
582 ) {
583 self.editor.update(cx, |editor, _| {
584 editor.set_nav_history(Some(nav_history));
585 });
586 }
587
588 fn clone_on_split(
589 &self,
590 _workspace_id: Option<workspace::WorkspaceId>,
591 window: &mut Window,
592 cx: &mut Context<Self>,
593 ) -> Option<Entity<Self>>
594 where
595 Self: Sized,
596 {
597 let workspace = self.workspace.upgrade()?;
598 Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx)))
599 }
600
601 fn is_dirty(&self, cx: &App) -> bool {
602 self.multibuffer.read(cx).is_dirty(cx)
603 }
604
605 fn has_conflict(&self, cx: &App) -> bool {
606 self.multibuffer.read(cx).has_conflict(cx)
607 }
608
609 fn can_save(&self, _: &App) -> bool {
610 true
611 }
612
613 fn save(
614 &mut self,
615 format: bool,
616 project: Entity<Project>,
617 window: &mut Window,
618 cx: &mut Context<Self>,
619 ) -> Task<Result<()>> {
620 self.editor.save(format, project, window, cx)
621 }
622
623 fn save_as(
624 &mut self,
625 _: Entity<Project>,
626 _: ProjectPath,
627 _window: &mut Window,
628 _: &mut Context<Self>,
629 ) -> Task<Result<()>> {
630 unreachable!()
631 }
632
633 fn reload(
634 &mut self,
635 project: Entity<Project>,
636 window: &mut Window,
637 cx: &mut Context<Self>,
638 ) -> Task<Result<()>> {
639 self.editor.reload(project, window, cx)
640 }
641
642 fn act_as_type<'a>(
643 &'a self,
644 type_id: TypeId,
645 self_handle: &'a Entity<Self>,
646 _: &'a App,
647 ) -> Option<AnyView> {
648 if type_id == TypeId::of::<Self>() {
649 Some(self_handle.to_any())
650 } else if type_id == TypeId::of::<Editor>() {
651 Some(self.editor.to_any())
652 } else {
653 None
654 }
655 }
656
657 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
658 ToolbarItemLocation::PrimaryLeft
659 }
660
661 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
662 self.editor.breadcrumbs(theme, cx)
663 }
664
665 fn added_to_workspace(
666 &mut self,
667 workspace: &mut Workspace,
668 window: &mut Window,
669 cx: &mut Context<Self>,
670 ) {
671 self.editor.update(cx, |editor, cx| {
672 editor.added_to_workspace(workspace, window, cx)
673 });
674 }
675}
676
677impl Render for ProjectDiff {
678 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
679 let is_empty = self.multibuffer.read(cx).is_empty();
680
681 let can_push_and_pull = crate::can_push_and_pull(&self.project, cx);
682
683 div()
684 .track_focus(&self.focus_handle)
685 .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
686 .bg(cx.theme().colors().editor_background)
687 .flex()
688 .items_center()
689 .justify_center()
690 .size_full()
691 .when(is_empty, |el| {
692 el.child(
693 v_flex()
694 .gap_1()
695 .child(
696 h_flex()
697 .justify_around()
698 .child(Label::new("No uncommitted changes")),
699 )
700 .when(can_push_and_pull, |this_div| {
701 let keybinding_focus_handle = self.focus_handle(cx);
702
703 this_div.when_some(self.current_branch.as_ref(), |this_div, branch| {
704 let remote_button = crate::render_remote_button(
705 "project-diff-remote-button",
706 branch,
707 Some(keybinding_focus_handle.clone()),
708 false,
709 );
710
711 match remote_button {
712 Some(button) => {
713 this_div.child(h_flex().justify_around().child(button))
714 }
715 None => this_div.child(
716 h_flex()
717 .justify_around()
718 .child(Label::new("Remote up to date")),
719 ),
720 }
721 })
722 })
723 .map(|this| {
724 let keybinding_focus_handle = self.focus_handle(cx).clone();
725
726 this.child(
727 h_flex().justify_around().mt_1().child(
728 Button::new("project-diff-close-button", "Close")
729 // .style(ButtonStyle::Transparent)
730 .key_binding(KeyBinding::for_action_in(
731 &CloseActiveItem::default(),
732 &keybinding_focus_handle,
733 window,
734 cx,
735 ))
736 .on_click(move |_, window, cx| {
737 window.focus(&keybinding_focus_handle);
738 window.dispatch_action(
739 Box::new(CloseActiveItem::default()),
740 cx,
741 );
742 }),
743 ),
744 )
745 }),
746 )
747 })
748 .when(!is_empty, |el| el.child(self.editor.clone()))
749 }
750}
751
752impl SerializableItem for ProjectDiff {
753 fn serialized_item_kind() -> &'static str {
754 "ProjectDiff"
755 }
756
757 fn cleanup(
758 _: workspace::WorkspaceId,
759 _: Vec<workspace::ItemId>,
760 _: &mut Window,
761 _: &mut App,
762 ) -> Task<Result<()>> {
763 Task::ready(Ok(()))
764 }
765
766 fn deserialize(
767 _project: Entity<Project>,
768 workspace: WeakEntity<Workspace>,
769 _workspace_id: workspace::WorkspaceId,
770 _item_id: workspace::ItemId,
771 window: &mut Window,
772 cx: &mut App,
773 ) -> Task<Result<Entity<Self>>> {
774 window.spawn(cx, |mut cx| async move {
775 workspace.update_in(&mut cx, |workspace, window, cx| {
776 let workspace_handle = cx.entity();
777 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
778 })
779 })
780 }
781
782 fn serialize(
783 &mut self,
784 _workspace: &mut Workspace,
785 _item_id: workspace::ItemId,
786 _closing: bool,
787 _window: &mut Window,
788 _cx: &mut Context<Self>,
789 ) -> Option<Task<Result<()>>> {
790 None
791 }
792
793 fn should_serialize(&self, _: &Self::Event) -> bool {
794 false
795 }
796}
797
798pub struct ProjectDiffToolbar {
799 project_diff: Option<WeakEntity<ProjectDiff>>,
800 workspace: WeakEntity<Workspace>,
801}
802
803impl ProjectDiffToolbar {
804 pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
805 Self {
806 project_diff: None,
807 workspace: workspace.weak_handle(),
808 }
809 }
810
811 fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
812 self.project_diff.as_ref()?.upgrade()
813 }
814 fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
815 if let Some(project_diff) = self.project_diff(cx) {
816 project_diff.focus_handle(cx).focus(window);
817 }
818 let action = action.boxed_clone();
819 cx.defer(move |cx| {
820 cx.dispatch_action(action.as_ref());
821 })
822 }
823
824 fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
825 self.workspace
826 .update(cx, |workspace, cx| {
827 if let Some(panel) = workspace.panel::<GitPanel>(cx) {
828 panel.update(cx, |panel, cx| {
829 panel.stage_all(&Default::default(), window, cx);
830 });
831 }
832 })
833 .ok();
834 }
835
836 fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
837 self.workspace
838 .update(cx, |workspace, cx| {
839 let Some(panel) = workspace.panel::<GitPanel>(cx) else {
840 return;
841 };
842 panel.update(cx, |panel, cx| {
843 panel.unstage_all(&Default::default(), window, cx);
844 });
845 })
846 .ok();
847 }
848}
849
850impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
851
852impl ToolbarItemView for ProjectDiffToolbar {
853 fn set_active_pane_item(
854 &mut self,
855 active_pane_item: Option<&dyn ItemHandle>,
856 _: &mut Window,
857 cx: &mut Context<Self>,
858 ) -> ToolbarItemLocation {
859 self.project_diff = active_pane_item
860 .and_then(|item| item.act_as::<ProjectDiff>(cx))
861 .map(|entity| entity.downgrade());
862 if self.project_diff.is_some() {
863 ToolbarItemLocation::PrimaryRight
864 } else {
865 ToolbarItemLocation::Hidden
866 }
867 }
868
869 fn pane_focus_update(
870 &mut self,
871 _pane_focused: bool,
872 _window: &mut Window,
873 _cx: &mut Context<Self>,
874 ) {
875 }
876}
877
878struct ButtonStates {
879 stage: bool,
880 unstage: bool,
881 prev_next: bool,
882 selection: bool,
883 stage_all: bool,
884 unstage_all: bool,
885}
886
887impl Render for ProjectDiffToolbar {
888 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
889 let Some(project_diff) = self.project_diff(cx) else {
890 return div();
891 };
892 let focus_handle = project_diff.focus_handle(cx);
893 let button_states = project_diff.read(cx).button_states(cx);
894
895 h_group_xl()
896 .my_neg_1()
897 .items_center()
898 .py_1()
899 .pl_2()
900 .pr_1()
901 .flex_wrap()
902 .justify_between()
903 .child(
904 h_group_sm()
905 .when(button_states.selection, |el| {
906 el.child(
907 Button::new("stage", "Toggle Staged")
908 .tooltip(Tooltip::for_action_title_in(
909 "Toggle Staged",
910 &ToggleStaged,
911 &focus_handle,
912 ))
913 .disabled(!button_states.stage && !button_states.unstage)
914 .on_click(cx.listener(|this, _, window, cx| {
915 this.dispatch_action(&ToggleStaged, window, cx)
916 })),
917 )
918 })
919 .when(!button_states.selection, |el| {
920 el.child(
921 Button::new("stage", "Stage")
922 .tooltip(Tooltip::for_action_title_in(
923 "Stage and go to next hunk",
924 &StageAndNext,
925 &focus_handle,
926 ))
927 .on_click(cx.listener(|this, _, window, cx| {
928 this.dispatch_action(&StageAndNext, window, cx)
929 })),
930 )
931 .child(
932 Button::new("unstage", "Unstage")
933 .tooltip(Tooltip::for_action_title_in(
934 "Unstage and go to next hunk",
935 &UnstageAndNext,
936 &focus_handle,
937 ))
938 .on_click(cx.listener(|this, _, window, cx| {
939 this.dispatch_action(&UnstageAndNext, window, cx)
940 })),
941 )
942 }),
943 )
944 // n.b. the only reason these arrows are here is because we don't
945 // support "undo" for staging so we need a way to go back.
946 .child(
947 h_group_sm()
948 .child(
949 IconButton::new("up", IconName::ArrowUp)
950 .shape(ui::IconButtonShape::Square)
951 .tooltip(Tooltip::for_action_title_in(
952 "Go to previous hunk",
953 &GoToPreviousHunk,
954 &focus_handle,
955 ))
956 .disabled(!button_states.prev_next)
957 .on_click(cx.listener(|this, _, window, cx| {
958 this.dispatch_action(&GoToPreviousHunk, window, cx)
959 })),
960 )
961 .child(
962 IconButton::new("down", IconName::ArrowDown)
963 .shape(ui::IconButtonShape::Square)
964 .tooltip(Tooltip::for_action_title_in(
965 "Go to next hunk",
966 &GoToHunk,
967 &focus_handle,
968 ))
969 .disabled(!button_states.prev_next)
970 .on_click(cx.listener(|this, _, window, cx| {
971 this.dispatch_action(&GoToHunk, window, cx)
972 })),
973 ),
974 )
975 .child(vertical_divider())
976 .child(
977 h_group_sm()
978 .when(
979 button_states.unstage_all && !button_states.stage_all,
980 |el| {
981 el.child(
982 Button::new("unstage-all", "Unstage All")
983 .tooltip(Tooltip::for_action_title_in(
984 "Unstage all changes",
985 &UnstageAll,
986 &focus_handle,
987 ))
988 .on_click(cx.listener(|this, _, window, cx| {
989 this.unstage_all(window, cx)
990 })),
991 )
992 },
993 )
994 .when(
995 !button_states.unstage_all || button_states.stage_all,
996 |el| {
997 el.child(
998 // todo make it so that changing to say "Unstaged"
999 // doesn't change the position.
1000 div().child(
1001 Button::new("stage-all", "Stage All")
1002 .disabled(!button_states.stage_all)
1003 .tooltip(Tooltip::for_action_title_in(
1004 "Stage all changes",
1005 &StageAll,
1006 &focus_handle,
1007 ))
1008 .on_click(cx.listener(|this, _, window, cx| {
1009 this.stage_all(window, cx)
1010 })),
1011 ),
1012 )
1013 },
1014 )
1015 .child(
1016 Button::new("commit", "Commit")
1017 .tooltip(Tooltip::for_action_title_in(
1018 "Commit",
1019 &Commit,
1020 &focus_handle,
1021 ))
1022 .on_click(cx.listener(|this, _, window, cx| {
1023 this.dispatch_action(&Commit, window, cx);
1024 })),
1025 ),
1026 )
1027 }
1028}
1029
1030#[derive(IntoElement, IntoComponent)]
1031#[component(scope = "Version Control")]
1032pub struct ProjectDiffEmptyState {
1033 pub no_repo: bool,
1034 pub can_push_and_pull: bool,
1035 pub focus_handle: Option<FocusHandle>,
1036 pub current_branch: Option<Branch>,
1037 // has_pending_commits: bool,
1038 // ahead_of_remote: bool,
1039 // no_git_repository: bool,
1040}
1041
1042impl RenderOnce for ProjectDiffEmptyState {
1043 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
1044 let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
1045 match self.current_branch {
1046 Some(Branch {
1047 upstream:
1048 Some(Upstream {
1049 tracking:
1050 UpstreamTracking::Tracked(UpstreamTrackingStatus {
1051 ahead, behind, ..
1052 }),
1053 ..
1054 }),
1055 ..
1056 }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0) => true,
1057 _ => false,
1058 }
1059 };
1060
1061 let change_count = |current_branch: &Branch| -> (usize, usize) {
1062 match current_branch {
1063 Branch {
1064 upstream:
1065 Some(Upstream {
1066 tracking:
1067 UpstreamTracking::Tracked(UpstreamTrackingStatus {
1068 ahead, behind, ..
1069 }),
1070 ..
1071 }),
1072 ..
1073 } => (*ahead as usize, *behind as usize),
1074 _ => (0, 0),
1075 }
1076 };
1077
1078 let not_ahead_or_behind = status_against_remote(0, 0);
1079 let ahead_of_remote = status_against_remote(1, 0);
1080 let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
1081 branch.upstream.is_none()
1082 } else {
1083 false
1084 };
1085
1086 let has_branch_container = |branch: &Branch| {
1087 h_flex()
1088 .max_w(px(420.))
1089 .bg(cx.theme().colors().text.opacity(0.05))
1090 .border_1()
1091 .border_color(cx.theme().colors().border)
1092 .rounded_sm()
1093 .gap_8()
1094 .px_6()
1095 .py_4()
1096 .map(|this| {
1097 if ahead_of_remote {
1098 let ahead_count = change_count(branch).0;
1099 let ahead_string = format!("{} Commits Ahead", ahead_count);
1100 this.child(
1101 v_flex()
1102 .child(Headline::new(ahead_string).size(HeadlineSize::Small))
1103 .child(
1104 Label::new(format!("Push your changes to {}", branch.name))
1105 .color(Color::Muted),
1106 ),
1107 )
1108 .child(div().child(render_push_button(
1109 self.focus_handle,
1110 "push".into(),
1111 ahead_count as u32,
1112 )))
1113 } else if branch_not_on_remote {
1114 this.child(
1115 v_flex()
1116 .child(Headline::new("Publish Branch").size(HeadlineSize::Small))
1117 .child(
1118 Label::new(format!("Create {} on remote", branch.name))
1119 .color(Color::Muted),
1120 ),
1121 )
1122 .child(
1123 div().child(render_publish_button(self.focus_handle, "publish".into())),
1124 )
1125 } else {
1126 this.child(Label::new("Remote status unknown").color(Color::Muted))
1127 }
1128 })
1129 };
1130
1131 v_flex().size_full().items_center().justify_center().child(
1132 v_flex()
1133 .gap_1()
1134 .when(self.no_repo, |this| {
1135 // TODO: add git init
1136 this.text_center()
1137 .child(Label::new("No Repository").color(Color::Muted))
1138 })
1139 .map(|this| {
1140 if not_ahead_or_behind && self.current_branch.is_some() {
1141 this.text_center()
1142 .child(Label::new("No Changes").color(Color::Muted))
1143 } else {
1144 this.when_some(self.current_branch.as_ref(), |this, branch| {
1145 this.child(has_branch_container(&branch))
1146 })
1147 }
1148 }),
1149 )
1150 }
1151}
1152
1153// .when(self.can_push_and_pull, |this| {
1154// let remote_button = crate::render_remote_button(
1155// "project-diff-remote-button",
1156// &branch,
1157// self.focus_handle.clone(),
1158// false,
1159// );
1160
1161// match remote_button {
1162// Some(button) => {
1163// this.child(h_flex().justify_around().child(button))
1164// }
1165// None => this.child(
1166// h_flex()
1167// .justify_around()
1168// .child(Label::new("Remote up to date")),
1169// ),
1170// }
1171// }),
1172//
1173// // .map(|this| {
1174// this.child(h_flex().justify_around().mt_1().child(
1175// Button::new("project-diff-close-button", "Close").when_some(
1176// self.focus_handle.clone(),
1177// |this, focus_handle| {
1178// this.key_binding(KeyBinding::for_action_in(
1179// &CloseActiveItem::default(),
1180// &focus_handle,
1181// window,
1182// cx,
1183// ))
1184// .on_click(move |_, window, cx| {
1185// window.focus(&focus_handle);
1186// window
1187// .dispatch_action(Box::new(CloseActiveItem::default()), cx);
1188// })
1189// },
1190// ),
1191// ))
1192// }),
1193
1194mod preview {
1195 use git::repository::{
1196 Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
1197 };
1198 use ui::prelude::*;
1199
1200 use super::ProjectDiffEmptyState;
1201
1202 // View this component preview using `workspace: open component-preview`
1203 impl ComponentPreview for ProjectDiffEmptyState {
1204 fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
1205 let unknown_upstream: Option<UpstreamTracking> = None;
1206 let ahead_of_upstream: Option<UpstreamTracking> = Some(
1207 UpstreamTrackingStatus {
1208 ahead: 2,
1209 behind: 0,
1210 }
1211 .into(),
1212 );
1213
1214 let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
1215 UpstreamTrackingStatus {
1216 ahead: 0,
1217 behind: 0,
1218 }
1219 .into(),
1220 );
1221
1222 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
1223 Branch {
1224 is_head: true,
1225 name: "some-branch".into(),
1226 upstream: upstream.map(|tracking| Upstream {
1227 ref_name: "origin/some-branch".into(),
1228 tracking,
1229 }),
1230 most_recent_commit: Some(CommitSummary {
1231 sha: "abc123".into(),
1232 subject: "Modify stuff".into(),
1233 commit_timestamp: 1710932954,
1234 has_parent: true,
1235 }),
1236 }
1237 }
1238
1239 let no_repo_state = ProjectDiffEmptyState {
1240 no_repo: true,
1241 can_push_and_pull: false,
1242 focus_handle: None,
1243 current_branch: None,
1244 };
1245
1246 let no_changes_state = ProjectDiffEmptyState {
1247 no_repo: false,
1248 can_push_and_pull: true,
1249 focus_handle: None,
1250 current_branch: Some(branch(not_ahead_or_behind_upstream)),
1251 };
1252
1253 let ahead_of_upstream_state = ProjectDiffEmptyState {
1254 no_repo: false,
1255 can_push_and_pull: true,
1256 focus_handle: None,
1257 current_branch: Some(branch(ahead_of_upstream)),
1258 };
1259
1260 let unknown_upstream_state = ProjectDiffEmptyState {
1261 no_repo: false,
1262 can_push_and_pull: true,
1263 focus_handle: None,
1264 current_branch: Some(branch(unknown_upstream)),
1265 };
1266
1267 let (width, height) = (px(480.), px(320.));
1268
1269 v_flex()
1270 .gap_6()
1271 .children(vec![example_group(vec![
1272 single_example(
1273 "No Repo",
1274 div()
1275 .w(width)
1276 .h(height)
1277 .child(no_repo_state)
1278 .into_any_element(),
1279 ),
1280 single_example(
1281 "No Changes",
1282 div()
1283 .w(width)
1284 .h(height)
1285 .child(no_changes_state)
1286 .into_any_element(),
1287 ),
1288 single_example(
1289 "Unknown Upstream",
1290 div()
1291 .w(width)
1292 .h(height)
1293 .child(unknown_upstream_state)
1294 .into_any_element(),
1295 ),
1296 single_example(
1297 "Ahead of Remote",
1298 div()
1299 .w(width)
1300 .h(height)
1301 .child(ahead_of_upstream_state)
1302 .into_any_element(),
1303 ),
1304 ])
1305 .vertical()])
1306 .into_any_element()
1307 }
1308 }
1309}
1310
1311#[cfg(not(target_os = "windows"))]
1312#[cfg(test)]
1313mod tests {
1314 use std::path::Path;
1315
1316 use collections::HashMap;
1317 use db::indoc;
1318 use editor::test::editor_test_context::{assert_state_with_diff, EditorTestContext};
1319 use git::status::{StatusCode, TrackedStatus};
1320 use gpui::TestAppContext;
1321 use project::FakeFs;
1322 use serde_json::json;
1323 use settings::SettingsStore;
1324 use unindent::Unindent as _;
1325 use util::path;
1326
1327 use super::*;
1328
1329 #[ctor::ctor]
1330 fn init_logger() {
1331 env_logger::init();
1332 }
1333
1334 fn init_test(cx: &mut TestAppContext) {
1335 cx.update(|cx| {
1336 let store = SettingsStore::test(cx);
1337 cx.set_global(store);
1338 theme::init(theme::LoadThemes::JustBase, cx);
1339 language::init(cx);
1340 Project::init_settings(cx);
1341 workspace::init_settings(cx);
1342 editor::init(cx);
1343 crate::init(cx);
1344 });
1345 }
1346
1347 #[gpui::test]
1348 async fn test_save_after_restore(cx: &mut TestAppContext) {
1349 init_test(cx);
1350
1351 let fs = FakeFs::new(cx.executor());
1352 fs.insert_tree(
1353 path!("/project"),
1354 json!({
1355 ".git": {},
1356 "foo.txt": "FOO\n",
1357 }),
1358 )
1359 .await;
1360 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1361 let (workspace, cx) =
1362 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1363 let diff = cx.new_window_entity(|window, cx| {
1364 ProjectDiff::new(project.clone(), workspace, window, cx)
1365 });
1366 cx.run_until_parked();
1367
1368 fs.set_head_for_repo(
1369 path!("/project/.git").as_ref(),
1370 &[("foo.txt".into(), "foo\n".into())],
1371 );
1372 fs.set_index_for_repo(
1373 path!("/project/.git").as_ref(),
1374 &[("foo.txt".into(), "foo\n".into())],
1375 );
1376 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1377 state.statuses = HashMap::from_iter([(
1378 "foo.txt".into(),
1379 TrackedStatus {
1380 index_status: StatusCode::Unmodified,
1381 worktree_status: StatusCode::Modified,
1382 }
1383 .into(),
1384 )]);
1385 });
1386 cx.run_until_parked();
1387
1388 let editor = diff.update(cx, |diff, _| diff.editor.clone());
1389 assert_state_with_diff(
1390 &editor,
1391 cx,
1392 &"
1393 - foo
1394 + ˇFOO
1395 "
1396 .unindent(),
1397 );
1398
1399 editor.update_in(cx, |editor, window, cx| {
1400 editor.git_restore(&Default::default(), window, cx);
1401 });
1402 cx.run_until_parked();
1403
1404 assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1405
1406 let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1407 assert_eq!(text, "foo\n");
1408 }
1409
1410 #[gpui::test]
1411 async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1412 init_test(cx);
1413
1414 let fs = FakeFs::new(cx.executor());
1415 fs.insert_tree(
1416 path!("/project"),
1417 json!({
1418 ".git": {},
1419 "bar": "BAR\n",
1420 "foo": "FOO\n",
1421 }),
1422 )
1423 .await;
1424 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1425 let (workspace, cx) =
1426 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1427 let diff = cx.new_window_entity(|window, cx| {
1428 ProjectDiff::new(project.clone(), workspace, window, cx)
1429 });
1430 cx.run_until_parked();
1431
1432 fs.set_head_for_repo(
1433 path!("/project/.git").as_ref(),
1434 &[
1435 ("bar".into(), "bar\n".into()),
1436 ("foo".into(), "foo\n".into()),
1437 ],
1438 );
1439 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1440 state.statuses = HashMap::from_iter([
1441 (
1442 "bar".into(),
1443 TrackedStatus {
1444 index_status: StatusCode::Unmodified,
1445 worktree_status: StatusCode::Modified,
1446 }
1447 .into(),
1448 ),
1449 (
1450 "foo".into(),
1451 TrackedStatus {
1452 index_status: StatusCode::Unmodified,
1453 worktree_status: StatusCode::Modified,
1454 }
1455 .into(),
1456 ),
1457 ]);
1458 });
1459 cx.run_until_parked();
1460
1461 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1462 diff.move_to_path(
1463 PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()),
1464 window,
1465 cx,
1466 );
1467 diff.editor.clone()
1468 });
1469 assert_state_with_diff(
1470 &editor,
1471 cx,
1472 &"
1473 - bar
1474 + BAR
1475
1476 - ˇfoo
1477 + FOO
1478 "
1479 .unindent(),
1480 );
1481
1482 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1483 diff.move_to_path(
1484 PathKey::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()),
1485 window,
1486 cx,
1487 );
1488 diff.editor.clone()
1489 });
1490 assert_state_with_diff(
1491 &editor,
1492 cx,
1493 &"
1494 - ˇbar
1495 + BAR
1496
1497 - foo
1498 + FOO
1499 "
1500 .unindent(),
1501 );
1502 }
1503
1504 #[gpui::test]
1505 async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1506 init_test(cx);
1507
1508 let fs = FakeFs::new(cx.executor());
1509 fs.insert_tree(
1510 path!("/project"),
1511 json!({
1512 ".git": {},
1513 "foo": "modified\n",
1514 }),
1515 )
1516 .await;
1517 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1518 let (workspace, cx) =
1519 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1520 let buffer = project
1521 .update(cx, |project, cx| {
1522 project.open_local_buffer(path!("/project/foo"), cx)
1523 })
1524 .await
1525 .unwrap();
1526 let buffer_editor = cx.new_window_entity(|window, cx| {
1527 Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1528 });
1529 let diff = cx.new_window_entity(|window, cx| {
1530 ProjectDiff::new(project.clone(), workspace, window, cx)
1531 });
1532 cx.run_until_parked();
1533
1534 fs.set_head_for_repo(
1535 path!("/project/.git").as_ref(),
1536 &[("foo".into(), "original\n".into())],
1537 );
1538 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1539 state.statuses = HashMap::from_iter([(
1540 "foo".into(),
1541 TrackedStatus {
1542 index_status: StatusCode::Unmodified,
1543 worktree_status: StatusCode::Modified,
1544 }
1545 .into(),
1546 )]);
1547 });
1548 cx.run_until_parked();
1549
1550 let diff_editor = diff.update(cx, |diff, _| diff.editor.clone());
1551
1552 assert_state_with_diff(
1553 &diff_editor,
1554 cx,
1555 &"
1556 - original
1557 + ˇmodified
1558 "
1559 .unindent(),
1560 );
1561
1562 let prev_buffer_hunks =
1563 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1564 let snapshot = buffer_editor.snapshot(window, cx);
1565 let snapshot = &snapshot.buffer_snapshot;
1566 let prev_buffer_hunks = buffer_editor
1567 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1568 .collect::<Vec<_>>();
1569 buffer_editor.git_restore(&Default::default(), window, cx);
1570 prev_buffer_hunks
1571 });
1572 assert_eq!(prev_buffer_hunks.len(), 1);
1573 cx.run_until_parked();
1574
1575 let new_buffer_hunks =
1576 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1577 let snapshot = buffer_editor.snapshot(window, cx);
1578 let snapshot = &snapshot.buffer_snapshot;
1579 let new_buffer_hunks = buffer_editor
1580 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1581 .collect::<Vec<_>>();
1582 buffer_editor.git_restore(&Default::default(), window, cx);
1583 new_buffer_hunks
1584 });
1585 assert_eq!(new_buffer_hunks.as_slice(), &[]);
1586
1587 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1588 buffer_editor.set_text("different\n", window, cx);
1589 buffer_editor.save(false, project.clone(), window, cx)
1590 })
1591 .await
1592 .unwrap();
1593
1594 cx.run_until_parked();
1595
1596 assert_state_with_diff(
1597 &diff_editor,
1598 cx,
1599 &"
1600 - original
1601 + ˇdifferent
1602 "
1603 .unindent(),
1604 );
1605 }
1606
1607 use crate::project_diff::{self, ProjectDiff};
1608
1609 #[gpui::test]
1610 async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
1611 init_test(cx);
1612
1613 let fs = FakeFs::new(cx.executor());
1614 fs.insert_tree(
1615 "/a",
1616 json!({
1617 ".git":{},
1618 "a.txt": "created\n",
1619 "b.txt": "really changed\n",
1620 "c.txt": "unchanged\n"
1621 }),
1622 )
1623 .await;
1624
1625 fs.set_git_content_for_repo(
1626 Path::new("/a/.git"),
1627 &[
1628 ("b.txt".into(), "before\n".to_string(), None),
1629 ("c.txt".into(), "unchanged\n".to_string(), None),
1630 ("d.txt".into(), "deleted\n".to_string(), None),
1631 ],
1632 );
1633
1634 let project = Project::test(fs, [Path::new("/a")], cx).await;
1635 let (workspace, cx) =
1636 cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1637
1638 cx.run_until_parked();
1639
1640 cx.focus(&workspace);
1641 cx.update(|window, cx| {
1642 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1643 });
1644
1645 cx.run_until_parked();
1646
1647 let item = workspace.update(cx, |workspace, cx| {
1648 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1649 });
1650 cx.focus(&item);
1651 let editor = item.update(cx, |item, _| item.editor.clone());
1652
1653 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1654
1655 cx.assert_excerpts_with_selections(indoc!(
1656 "
1657 [EXCERPT]
1658 before
1659 really changed
1660 [EXCERPT]
1661 [FOLDED]
1662 [EXCERPT]
1663 ˇcreated
1664 "
1665 ));
1666
1667 cx.dispatch_action(editor::actions::GoToPreviousHunk);
1668
1669 cx.assert_excerpts_with_selections(indoc!(
1670 "
1671 [EXCERPT]
1672 before
1673 really changed
1674 [EXCERPT]
1675 ˇ[FOLDED]
1676 [EXCERPT]
1677 created
1678 "
1679 ));
1680
1681 cx.dispatch_action(editor::actions::GoToPreviousHunk);
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}