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