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