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 // don't actually disable the button so it's mashable
928 .color(if button_states.stage {
929 Color::Default
930 } else {
931 Color::Disabled
932 })
933 .on_click(cx.listener(|this, _, window, cx| {
934 this.dispatch_action(&StageAndNext, window, cx)
935 })),
936 )
937 .child(
938 Button::new("unstage", "Unstage")
939 .tooltip(Tooltip::for_action_title_in(
940 "Unstage and go to next hunk",
941 &UnstageAndNext,
942 &focus_handle,
943 ))
944 .color(if button_states.unstage {
945 Color::Default
946 } else {
947 Color::Disabled
948 })
949 .on_click(cx.listener(|this, _, window, cx| {
950 this.dispatch_action(&UnstageAndNext, window, cx)
951 })),
952 )
953 }),
954 )
955 // n.b. the only reason these arrows are here is because we don't
956 // support "undo" for staging so we need a way to go back.
957 .child(
958 h_group_sm()
959 .child(
960 IconButton::new("up", IconName::ArrowUp)
961 .shape(ui::IconButtonShape::Square)
962 .tooltip(Tooltip::for_action_title_in(
963 "Go to previous hunk",
964 &GoToPreviousHunk,
965 &focus_handle,
966 ))
967 .disabled(!button_states.prev_next)
968 .on_click(cx.listener(|this, _, window, cx| {
969 this.dispatch_action(&GoToPreviousHunk, window, cx)
970 })),
971 )
972 .child(
973 IconButton::new("down", IconName::ArrowDown)
974 .shape(ui::IconButtonShape::Square)
975 .tooltip(Tooltip::for_action_title_in(
976 "Go to next hunk",
977 &GoToHunk,
978 &focus_handle,
979 ))
980 .disabled(!button_states.prev_next)
981 .on_click(cx.listener(|this, _, window, cx| {
982 this.dispatch_action(&GoToHunk, window, cx)
983 })),
984 ),
985 )
986 .child(vertical_divider())
987 .child(
988 h_group_sm()
989 .when(
990 button_states.unstage_all && !button_states.stage_all,
991 |el| {
992 el.child(
993 Button::new("unstage-all", "Unstage All")
994 .tooltip(Tooltip::for_action_title_in(
995 "Unstage all changes",
996 &UnstageAll,
997 &focus_handle,
998 ))
999 .on_click(cx.listener(|this, _, window, cx| {
1000 this.unstage_all(window, cx)
1001 })),
1002 )
1003 },
1004 )
1005 .when(
1006 !button_states.unstage_all || button_states.stage_all,
1007 |el| {
1008 el.child(
1009 // todo make it so that changing to say "Unstaged"
1010 // doesn't change the position.
1011 div().child(
1012 Button::new("stage-all", "Stage All")
1013 .disabled(!button_states.stage_all)
1014 .tooltip(Tooltip::for_action_title_in(
1015 "Stage all changes",
1016 &StageAll,
1017 &focus_handle,
1018 ))
1019 .on_click(cx.listener(|this, _, window, cx| {
1020 this.stage_all(window, cx)
1021 })),
1022 ),
1023 )
1024 },
1025 )
1026 .child(
1027 Button::new("commit", "Commit")
1028 .tooltip(Tooltip::for_action_title_in(
1029 "Commit",
1030 &Commit,
1031 &focus_handle,
1032 ))
1033 .on_click(cx.listener(|this, _, window, cx| {
1034 this.dispatch_action(&Commit, window, cx);
1035 })),
1036 ),
1037 )
1038 }
1039}
1040
1041#[derive(IntoElement, IntoComponent)]
1042#[component(scope = "Version Control")]
1043pub struct ProjectDiffEmptyState {
1044 pub no_repo: bool,
1045 pub can_push_and_pull: bool,
1046 pub focus_handle: Option<FocusHandle>,
1047 pub current_branch: Option<Branch>,
1048 // has_pending_commits: bool,
1049 // ahead_of_remote: bool,
1050 // no_git_repository: bool,
1051}
1052
1053impl RenderOnce for ProjectDiffEmptyState {
1054 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
1055 let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
1056 match self.current_branch {
1057 Some(Branch {
1058 upstream:
1059 Some(Upstream {
1060 tracking:
1061 UpstreamTracking::Tracked(UpstreamTrackingStatus {
1062 ahead, behind, ..
1063 }),
1064 ..
1065 }),
1066 ..
1067 }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0) => true,
1068 _ => false,
1069 }
1070 };
1071
1072 let change_count = |current_branch: &Branch| -> (usize, usize) {
1073 match current_branch {
1074 Branch {
1075 upstream:
1076 Some(Upstream {
1077 tracking:
1078 UpstreamTracking::Tracked(UpstreamTrackingStatus {
1079 ahead, behind, ..
1080 }),
1081 ..
1082 }),
1083 ..
1084 } => (*ahead as usize, *behind as usize),
1085 _ => (0, 0),
1086 }
1087 };
1088
1089 let not_ahead_or_behind = status_against_remote(0, 0);
1090 let ahead_of_remote = status_against_remote(1, 0);
1091 let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
1092 branch.upstream.is_none()
1093 } else {
1094 false
1095 };
1096
1097 let has_branch_container = |branch: &Branch| {
1098 h_flex()
1099 .max_w(px(420.))
1100 .bg(cx.theme().colors().text.opacity(0.05))
1101 .border_1()
1102 .border_color(cx.theme().colors().border)
1103 .rounded_sm()
1104 .gap_8()
1105 .px_6()
1106 .py_4()
1107 .map(|this| {
1108 if ahead_of_remote {
1109 let ahead_count = change_count(branch).0;
1110 let ahead_string = format!("{} Commits Ahead", ahead_count);
1111 this.child(
1112 v_flex()
1113 .child(Headline::new(ahead_string).size(HeadlineSize::Small))
1114 .child(
1115 Label::new(format!("Push your changes to {}", branch.name))
1116 .color(Color::Muted),
1117 ),
1118 )
1119 .child(div().child(render_push_button(
1120 self.focus_handle,
1121 "push".into(),
1122 ahead_count as u32,
1123 )))
1124 } else if branch_not_on_remote {
1125 this.child(
1126 v_flex()
1127 .child(Headline::new("Publish Branch").size(HeadlineSize::Small))
1128 .child(
1129 Label::new(format!("Create {} on remote", branch.name))
1130 .color(Color::Muted),
1131 ),
1132 )
1133 .child(
1134 div().child(render_publish_button(self.focus_handle, "publish".into())),
1135 )
1136 } else {
1137 this.child(Label::new("Remote status unknown").color(Color::Muted))
1138 }
1139 })
1140 };
1141
1142 v_flex().size_full().items_center().justify_center().child(
1143 v_flex()
1144 .gap_1()
1145 .when(self.no_repo, |this| {
1146 // TODO: add git init
1147 this.text_center()
1148 .child(Label::new("No Repository").color(Color::Muted))
1149 })
1150 .map(|this| {
1151 if not_ahead_or_behind && self.current_branch.is_some() {
1152 this.text_center()
1153 .child(Label::new("No Changes").color(Color::Muted))
1154 } else {
1155 this.when_some(self.current_branch.as_ref(), |this, branch| {
1156 this.child(has_branch_container(&branch))
1157 })
1158 }
1159 }),
1160 )
1161 }
1162}
1163
1164// .when(self.can_push_and_pull, |this| {
1165// let remote_button = crate::render_remote_button(
1166// "project-diff-remote-button",
1167// &branch,
1168// self.focus_handle.clone(),
1169// false,
1170// );
1171
1172// match remote_button {
1173// Some(button) => {
1174// this.child(h_flex().justify_around().child(button))
1175// }
1176// None => this.child(
1177// h_flex()
1178// .justify_around()
1179// .child(Label::new("Remote up to date")),
1180// ),
1181// }
1182// }),
1183//
1184// // .map(|this| {
1185// this.child(h_flex().justify_around().mt_1().child(
1186// Button::new("project-diff-close-button", "Close").when_some(
1187// self.focus_handle.clone(),
1188// |this, focus_handle| {
1189// this.key_binding(KeyBinding::for_action_in(
1190// &CloseActiveItem::default(),
1191// &focus_handle,
1192// window,
1193// cx,
1194// ))
1195// .on_click(move |_, window, cx| {
1196// window.focus(&focus_handle);
1197// window
1198// .dispatch_action(Box::new(CloseActiveItem::default()), cx);
1199// })
1200// },
1201// ),
1202// ))
1203// }),
1204
1205mod preview {
1206 use git::repository::{
1207 Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
1208 };
1209 use ui::prelude::*;
1210
1211 use super::ProjectDiffEmptyState;
1212
1213 // View this component preview using `workspace: open component-preview`
1214 impl ComponentPreview for ProjectDiffEmptyState {
1215 fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
1216 let unknown_upstream: Option<UpstreamTracking> = None;
1217 let ahead_of_upstream: Option<UpstreamTracking> = Some(
1218 UpstreamTrackingStatus {
1219 ahead: 2,
1220 behind: 0,
1221 }
1222 .into(),
1223 );
1224
1225 let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
1226 UpstreamTrackingStatus {
1227 ahead: 0,
1228 behind: 0,
1229 }
1230 .into(),
1231 );
1232
1233 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
1234 Branch {
1235 is_head: true,
1236 name: "some-branch".into(),
1237 upstream: upstream.map(|tracking| Upstream {
1238 ref_name: "origin/some-branch".into(),
1239 tracking,
1240 }),
1241 most_recent_commit: Some(CommitSummary {
1242 sha: "abc123".into(),
1243 subject: "Modify stuff".into(),
1244 commit_timestamp: 1710932954,
1245 has_parent: true,
1246 }),
1247 }
1248 }
1249
1250 let no_repo_state = ProjectDiffEmptyState {
1251 no_repo: true,
1252 can_push_and_pull: false,
1253 focus_handle: None,
1254 current_branch: None,
1255 };
1256
1257 let no_changes_state = ProjectDiffEmptyState {
1258 no_repo: false,
1259 can_push_and_pull: true,
1260 focus_handle: None,
1261 current_branch: Some(branch(not_ahead_or_behind_upstream)),
1262 };
1263
1264 let ahead_of_upstream_state = ProjectDiffEmptyState {
1265 no_repo: false,
1266 can_push_and_pull: true,
1267 focus_handle: None,
1268 current_branch: Some(branch(ahead_of_upstream)),
1269 };
1270
1271 let unknown_upstream_state = ProjectDiffEmptyState {
1272 no_repo: false,
1273 can_push_and_pull: true,
1274 focus_handle: None,
1275 current_branch: Some(branch(unknown_upstream)),
1276 };
1277
1278 let (width, height) = (px(480.), px(320.));
1279
1280 v_flex()
1281 .gap_6()
1282 .children(vec![example_group(vec![
1283 single_example(
1284 "No Repo",
1285 div()
1286 .w(width)
1287 .h(height)
1288 .child(no_repo_state)
1289 .into_any_element(),
1290 ),
1291 single_example(
1292 "No Changes",
1293 div()
1294 .w(width)
1295 .h(height)
1296 .child(no_changes_state)
1297 .into_any_element(),
1298 ),
1299 single_example(
1300 "Unknown Upstream",
1301 div()
1302 .w(width)
1303 .h(height)
1304 .child(unknown_upstream_state)
1305 .into_any_element(),
1306 ),
1307 single_example(
1308 "Ahead of Remote",
1309 div()
1310 .w(width)
1311 .h(height)
1312 .child(ahead_of_upstream_state)
1313 .into_any_element(),
1314 ),
1315 ])
1316 .vertical()])
1317 .into_any_element()
1318 }
1319 }
1320}
1321
1322#[cfg(not(target_os = "windows"))]
1323#[cfg(test)]
1324mod tests {
1325 use std::path::Path;
1326
1327 use collections::HashMap;
1328 use db::indoc;
1329 use editor::test::editor_test_context::{assert_state_with_diff, EditorTestContext};
1330 use git::status::{StatusCode, TrackedStatus};
1331 use gpui::TestAppContext;
1332 use project::FakeFs;
1333 use serde_json::json;
1334 use settings::SettingsStore;
1335 use unindent::Unindent as _;
1336 use util::path;
1337
1338 use super::*;
1339
1340 #[ctor::ctor]
1341 fn init_logger() {
1342 env_logger::init();
1343 }
1344
1345 fn init_test(cx: &mut TestAppContext) {
1346 cx.update(|cx| {
1347 let store = SettingsStore::test(cx);
1348 cx.set_global(store);
1349 theme::init(theme::LoadThemes::JustBase, cx);
1350 language::init(cx);
1351 Project::init_settings(cx);
1352 workspace::init_settings(cx);
1353 editor::init(cx);
1354 crate::init(cx);
1355 });
1356 }
1357
1358 #[gpui::test]
1359 async fn test_save_after_restore(cx: &mut TestAppContext) {
1360 init_test(cx);
1361
1362 let fs = FakeFs::new(cx.executor());
1363 fs.insert_tree(
1364 path!("/project"),
1365 json!({
1366 ".git": {},
1367 "foo.txt": "FOO\n",
1368 }),
1369 )
1370 .await;
1371 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1372 let (workspace, cx) =
1373 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1374 let diff = cx.new_window_entity(|window, cx| {
1375 ProjectDiff::new(project.clone(), workspace, window, cx)
1376 });
1377 cx.run_until_parked();
1378
1379 fs.set_head_for_repo(
1380 path!("/project/.git").as_ref(),
1381 &[("foo.txt".into(), "foo\n".into())],
1382 );
1383 fs.set_index_for_repo(
1384 path!("/project/.git").as_ref(),
1385 &[("foo.txt".into(), "foo\n".into())],
1386 );
1387 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1388 state.statuses = HashMap::from_iter([(
1389 "foo.txt".into(),
1390 TrackedStatus {
1391 index_status: StatusCode::Unmodified,
1392 worktree_status: StatusCode::Modified,
1393 }
1394 .into(),
1395 )]);
1396 });
1397 cx.run_until_parked();
1398
1399 let editor = diff.update(cx, |diff, _| diff.editor.clone());
1400 assert_state_with_diff(
1401 &editor,
1402 cx,
1403 &"
1404 - foo
1405 + ˇFOO
1406 "
1407 .unindent(),
1408 );
1409
1410 editor.update_in(cx, |editor, window, cx| {
1411 editor.git_restore(&Default::default(), window, cx);
1412 });
1413 cx.run_until_parked();
1414
1415 assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1416
1417 let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1418 assert_eq!(text, "foo\n");
1419 }
1420
1421 #[gpui::test]
1422 async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1423 init_test(cx);
1424
1425 let fs = FakeFs::new(cx.executor());
1426 fs.insert_tree(
1427 path!("/project"),
1428 json!({
1429 ".git": {},
1430 "bar": "BAR\n",
1431 "foo": "FOO\n",
1432 }),
1433 )
1434 .await;
1435 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1436 let (workspace, cx) =
1437 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1438 let diff = cx.new_window_entity(|window, cx| {
1439 ProjectDiff::new(project.clone(), workspace, window, cx)
1440 });
1441 cx.run_until_parked();
1442
1443 fs.set_head_for_repo(
1444 path!("/project/.git").as_ref(),
1445 &[
1446 ("bar".into(), "bar\n".into()),
1447 ("foo".into(), "foo\n".into()),
1448 ],
1449 );
1450 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1451 state.statuses = HashMap::from_iter([
1452 (
1453 "bar".into(),
1454 TrackedStatus {
1455 index_status: StatusCode::Unmodified,
1456 worktree_status: StatusCode::Modified,
1457 }
1458 .into(),
1459 ),
1460 (
1461 "foo".into(),
1462 TrackedStatus {
1463 index_status: StatusCode::Unmodified,
1464 worktree_status: StatusCode::Modified,
1465 }
1466 .into(),
1467 ),
1468 ]);
1469 });
1470 cx.run_until_parked();
1471
1472 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1473 diff.move_to_path(
1474 PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()),
1475 window,
1476 cx,
1477 );
1478 diff.editor.clone()
1479 });
1480 assert_state_with_diff(
1481 &editor,
1482 cx,
1483 &"
1484 - bar
1485 + BAR
1486
1487 - ˇfoo
1488 + FOO
1489 "
1490 .unindent(),
1491 );
1492
1493 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1494 diff.move_to_path(
1495 PathKey::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()),
1496 window,
1497 cx,
1498 );
1499 diff.editor.clone()
1500 });
1501 assert_state_with_diff(
1502 &editor,
1503 cx,
1504 &"
1505 - ˇbar
1506 + BAR
1507
1508 - foo
1509 + FOO
1510 "
1511 .unindent(),
1512 );
1513 }
1514
1515 #[gpui::test]
1516 async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1517 init_test(cx);
1518
1519 let fs = FakeFs::new(cx.executor());
1520 fs.insert_tree(
1521 path!("/project"),
1522 json!({
1523 ".git": {},
1524 "foo": "modified\n",
1525 }),
1526 )
1527 .await;
1528 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1529 let (workspace, cx) =
1530 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1531 let buffer = project
1532 .update(cx, |project, cx| {
1533 project.open_local_buffer(path!("/project/foo"), cx)
1534 })
1535 .await
1536 .unwrap();
1537 let buffer_editor = cx.new_window_entity(|window, cx| {
1538 Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1539 });
1540 let diff = cx.new_window_entity(|window, cx| {
1541 ProjectDiff::new(project.clone(), workspace, window, cx)
1542 });
1543 cx.run_until_parked();
1544
1545 fs.set_head_for_repo(
1546 path!("/project/.git").as_ref(),
1547 &[("foo".into(), "original\n".into())],
1548 );
1549 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1550 state.statuses = HashMap::from_iter([(
1551 "foo".into(),
1552 TrackedStatus {
1553 index_status: StatusCode::Unmodified,
1554 worktree_status: StatusCode::Modified,
1555 }
1556 .into(),
1557 )]);
1558 });
1559 cx.run_until_parked();
1560
1561 let diff_editor = diff.update(cx, |diff, _| diff.editor.clone());
1562
1563 assert_state_with_diff(
1564 &diff_editor,
1565 cx,
1566 &"
1567 - original
1568 + ˇmodified
1569 "
1570 .unindent(),
1571 );
1572
1573 let prev_buffer_hunks =
1574 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1575 let snapshot = buffer_editor.snapshot(window, cx);
1576 let snapshot = &snapshot.buffer_snapshot;
1577 let prev_buffer_hunks = buffer_editor
1578 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1579 .collect::<Vec<_>>();
1580 buffer_editor.git_restore(&Default::default(), window, cx);
1581 prev_buffer_hunks
1582 });
1583 assert_eq!(prev_buffer_hunks.len(), 1);
1584 cx.run_until_parked();
1585
1586 let new_buffer_hunks =
1587 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1588 let snapshot = buffer_editor.snapshot(window, cx);
1589 let snapshot = &snapshot.buffer_snapshot;
1590 let new_buffer_hunks = buffer_editor
1591 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1592 .collect::<Vec<_>>();
1593 buffer_editor.git_restore(&Default::default(), window, cx);
1594 new_buffer_hunks
1595 });
1596 assert_eq!(new_buffer_hunks.as_slice(), &[]);
1597
1598 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1599 buffer_editor.set_text("different\n", window, cx);
1600 buffer_editor.save(false, project.clone(), window, cx)
1601 })
1602 .await
1603 .unwrap();
1604
1605 cx.run_until_parked();
1606
1607 assert_state_with_diff(
1608 &diff_editor,
1609 cx,
1610 &"
1611 - original
1612 + ˇdifferent
1613 "
1614 .unindent(),
1615 );
1616 }
1617
1618 use crate::project_diff::{self, ProjectDiff};
1619
1620 #[gpui::test]
1621 async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
1622 init_test(cx);
1623
1624 let fs = FakeFs::new(cx.executor());
1625 fs.insert_tree(
1626 "/a",
1627 json!({
1628 ".git":{},
1629 "a.txt": "created\n",
1630 "b.txt": "really changed\n",
1631 "c.txt": "unchanged\n"
1632 }),
1633 )
1634 .await;
1635
1636 fs.set_git_content_for_repo(
1637 Path::new("/a/.git"),
1638 &[
1639 ("b.txt".into(), "before\n".to_string(), None),
1640 ("c.txt".into(), "unchanged\n".to_string(), None),
1641 ("d.txt".into(), "deleted\n".to_string(), None),
1642 ],
1643 );
1644
1645 let project = Project::test(fs, [Path::new("/a")], cx).await;
1646 let (workspace, cx) =
1647 cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1648
1649 cx.run_until_parked();
1650
1651 cx.focus(&workspace);
1652 cx.update(|window, cx| {
1653 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1654 });
1655
1656 cx.run_until_parked();
1657
1658 let item = workspace.update(cx, |workspace, cx| {
1659 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1660 });
1661 cx.focus(&item);
1662 let editor = item.update(cx, |item, _| item.editor.clone());
1663
1664 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1665
1666 cx.assert_excerpts_with_selections(indoc!(
1667 "
1668 [EXCERPT]
1669 before
1670 really changed
1671 [EXCERPT]
1672 [FOLDED]
1673 [EXCERPT]
1674 ˇcreated
1675 "
1676 ));
1677
1678 cx.dispatch_action(editor::actions::GoToPreviousHunk);
1679
1680 cx.assert_excerpts_with_selections(indoc!(
1681 "
1682 [EXCERPT]
1683 before
1684 really changed
1685 [EXCERPT]
1686 ˇ[FOLDED]
1687 [EXCERPT]
1688 created
1689 "
1690 ));
1691
1692 cx.dispatch_action(editor::actions::GoToPreviousHunk);
1693
1694 cx.assert_excerpts_with_selections(indoc!(
1695 "
1696 [EXCERPT]
1697 ˇbefore
1698 really changed
1699 [EXCERPT]
1700 [FOLDED]
1701 [EXCERPT]
1702 created
1703 "
1704 ));
1705 }
1706}