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