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