1use crate::git_panel::{GitPanel, GitPanelAddon, GitStatusEntry};
2use anyhow::Result;
3use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
4use collections::HashSet;
5use editor::{
6 actions::{GoToHunk, GoToPreviousHunk},
7 scroll::Autoscroll,
8 Editor, EditorEvent,
9};
10use feature_flags::FeatureFlagViewExt;
11use futures::StreamExt;
12use git::{
13 status::FileStatus, Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
14};
15use gpui::{
16 actions, Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity,
17 EventEmitter, FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
18};
19use language::{Anchor, Buffer, Capability, OffsetRangeExt};
20use multi_buffer::{MultiBuffer, PathKey};
21use project::{
22 git::{GitEvent, GitStore},
23 Project, ProjectPath,
24};
25use std::any::{Any, TypeId};
26use theme::ActiveTheme;
27use ui::{prelude::*, vertical_divider, Tooltip};
28use util::ResultExt as _;
29use workspace::{
30 item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
31 searchable::SearchableItemHandle,
32 ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
33 Workspace,
34};
35
36actions!(git, [Diff]);
37
38pub struct ProjectDiff {
39 multibuffer: Entity<MultiBuffer>,
40 editor: Entity<Editor>,
41 project: Entity<Project>,
42 git_store: Entity<GitStore>,
43 workspace: WeakEntity<Workspace>,
44 focus_handle: FocusHandle,
45 update_needed: postage::watch::Sender<()>,
46 pending_scroll: Option<PathKey>,
47
48 _task: Task<Result<()>>,
49 _subscription: Subscription,
50}
51
52#[derive(Debug)]
53struct DiffBuffer {
54 path_key: PathKey,
55 buffer: Entity<Buffer>,
56 diff: Entity<BufferDiff>,
57 file_status: FileStatus,
58}
59
60const CONFLICT_NAMESPACE: &'static str = "0";
61const TRACKED_NAMESPACE: &'static str = "1";
62const NEW_NAMESPACE: &'static str = "2";
63
64impl ProjectDiff {
65 pub(crate) fn register(
66 _: &mut Workspace,
67 window: Option<&mut Window>,
68 cx: &mut Context<Workspace>,
69 ) {
70 let Some(window) = window else { return };
71 cx.when_flag_enabled::<feature_flags::GitUiFeatureFlag>(window, |workspace, _, _cx| {
72 workspace.register_action(Self::deploy);
73 });
74
75 workspace::register_serializable_item::<ProjectDiff>(cx);
76 }
77
78 fn deploy(
79 workspace: &mut Workspace,
80 _: &Diff,
81 window: &mut Window,
82 cx: &mut Context<Workspace>,
83 ) {
84 Self::deploy_at(workspace, None, window, cx)
85 }
86
87 pub fn deploy_at(
88 workspace: &mut Workspace,
89 entry: Option<GitStatusEntry>,
90 window: &mut Window,
91 cx: &mut Context<Workspace>,
92 ) {
93 telemetry::event!(
94 "Git Diff Opened",
95 source = if entry.is_some() {
96 "Git Panel"
97 } else {
98 "Action"
99 }
100 );
101 let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
102 workspace.activate_item(&existing, true, true, window, cx);
103 existing
104 } else {
105 let workspace_handle = cx.entity();
106 let project_diff =
107 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
108 workspace.add_item_to_active_pane(
109 Box::new(project_diff.clone()),
110 None,
111 true,
112 window,
113 cx,
114 );
115 project_diff
116 };
117 if let Some(entry) = entry {
118 project_diff.update(cx, |project_diff, cx| {
119 project_diff.move_to_entry(entry, window, cx);
120 })
121 }
122 }
123
124 fn new(
125 project: Entity<Project>,
126 workspace: Entity<Workspace>,
127 window: &mut Window,
128 cx: &mut Context<Self>,
129 ) -> Self {
130 let focus_handle = cx.focus_handle();
131 let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
132
133 let editor = cx.new(|cx| {
134 let mut diff_display_editor = Editor::for_multibuffer(
135 multibuffer.clone(),
136 Some(project.clone()),
137 true,
138 window,
139 cx,
140 );
141 diff_display_editor.disable_inline_diagnostics();
142 diff_display_editor.set_expand_all_diff_hunks(cx);
143 diff_display_editor.register_addon(GitPanelAddon {
144 workspace: workspace.downgrade(),
145 });
146 diff_display_editor
147 });
148 cx.subscribe_in(&editor, window, Self::handle_editor_event)
149 .detach();
150
151 let git_store = project.read(cx).git_store().clone();
152 let git_store_subscription = cx.subscribe_in(
153 &git_store,
154 window,
155 move |this, _git_store, event, _window, _cx| match event {
156 GitEvent::ActiveRepositoryChanged
157 | GitEvent::FileSystemUpdated
158 | GitEvent::GitStateUpdated => {
159 *this.update_needed.borrow_mut() = ();
160 }
161 _ => {}
162 },
163 );
164
165 let (mut send, recv) = postage::watch::channel::<()>();
166 let worker = window.spawn(cx, {
167 let this = cx.weak_entity();
168 |cx| Self::handle_status_updates(this, recv, cx)
169 });
170 // Kick off a refresh immediately
171 *send.borrow_mut() = ();
172
173 Self {
174 project,
175 git_store: git_store.clone(),
176 workspace: workspace.downgrade(),
177 focus_handle,
178 editor,
179 multibuffer,
180 pending_scroll: None,
181 update_needed: send,
182 _task: worker,
183 _subscription: git_store_subscription,
184 }
185 }
186
187 pub fn move_to_entry(
188 &mut self,
189 entry: GitStatusEntry,
190 window: &mut Window,
191 cx: &mut Context<Self>,
192 ) {
193 let Some(git_repo) = self.git_store.read(cx).active_repository() else {
194 return;
195 };
196 let repo = git_repo.read(cx);
197
198 let namespace = if repo.has_conflict(&entry.repo_path) {
199 CONFLICT_NAMESPACE
200 } else if entry.status.is_created() {
201 NEW_NAMESPACE
202 } else {
203 TRACKED_NAMESPACE
204 };
205
206 let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
207
208 self.move_to_path(path_key, window, cx)
209 }
210
211 pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
212 let editor = self.editor.read(cx);
213 let position = editor.selections.newest_anchor().head();
214 let multi_buffer = editor.buffer().read(cx);
215 let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
216
217 let file = buffer.read(cx).file()?;
218 Some(ProjectPath {
219 worktree_id: file.worktree_id(cx),
220 path: file.path().clone(),
221 })
222 }
223
224 fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
225 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
226 self.editor.update(cx, |editor, cx| {
227 editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
228 s.select_ranges([position..position]);
229 })
230 });
231 } else {
232 self.pending_scroll = Some(path_key);
233 }
234 }
235
236 fn button_states(&self, cx: &App) -> ButtonStates {
237 let editor = self.editor.read(cx);
238 let snapshot = self.multibuffer.read(cx).snapshot(cx);
239 let prev_next = snapshot.diff_hunks().skip(1).next().is_some();
240 let mut selection = true;
241
242 let mut ranges = editor
243 .selections
244 .disjoint_anchor_ranges()
245 .collect::<Vec<_>>();
246 if !ranges.iter().any(|range| range.start != range.end) {
247 selection = false;
248 if let Some((excerpt_id, buffer, range)) = self.editor.read(cx).active_excerpt(cx) {
249 ranges = vec![multi_buffer::Anchor::range_in_buffer(
250 excerpt_id,
251 buffer.read(cx).remote_id(),
252 range,
253 )];
254 } else {
255 ranges = Vec::default();
256 }
257 }
258 let mut has_staged_hunks = false;
259 let mut has_unstaged_hunks = false;
260 for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
261 match hunk.secondary_status {
262 DiffHunkSecondaryStatus::HasSecondaryHunk
263 | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
264 has_unstaged_hunks = true;
265 }
266 DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
267 has_staged_hunks = true;
268 has_unstaged_hunks = true;
269 }
270 DiffHunkSecondaryStatus::None
271 | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
272 has_staged_hunks = true;
273 }
274 }
275 }
276 let mut stage_all = false;
277 let mut unstage_all = false;
278 self.workspace
279 .read_with(cx, |workspace, cx| {
280 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
281 let git_panel = git_panel.read(cx);
282 stage_all = git_panel.can_stage_all();
283 unstage_all = git_panel.can_unstage_all();
284 }
285 })
286 .ok();
287
288 return ButtonStates {
289 stage: has_unstaged_hunks,
290 unstage: has_staged_hunks,
291 prev_next,
292 selection,
293 stage_all,
294 unstage_all,
295 };
296 }
297
298 fn handle_editor_event(
299 &mut self,
300 _: &Entity<Editor>,
301 event: &EditorEvent,
302 window: &mut Window,
303 cx: &mut Context<Self>,
304 ) {
305 match event {
306 EditorEvent::SelectionsChanged { local: true } => {
307 let Some(project_path) = self.active_path(cx) else {
308 return;
309 };
310 self.workspace
311 .update(cx, |workspace, cx| {
312 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
313 git_panel.update(cx, |git_panel, cx| {
314 git_panel.select_entry_by_path(project_path, window, cx)
315 })
316 }
317 })
318 .ok();
319 }
320 _ => {}
321 }
322 }
323
324 fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
325 let Some(repo) = self.git_store.read(cx).active_repository() else {
326 self.multibuffer.update(cx, |multibuffer, cx| {
327 multibuffer.clear(cx);
328 });
329 return vec![];
330 };
331
332 let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
333
334 let mut result = vec![];
335 repo.update(cx, |repo, cx| {
336 for entry in repo.status() {
337 if !entry.status.has_changes() {
338 continue;
339 }
340 let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
341 continue;
342 };
343 let namespace = if repo.has_conflict(&entry.repo_path) {
344 CONFLICT_NAMESPACE
345 } else if entry.status.is_created() {
346 NEW_NAMESPACE
347 } else {
348 TRACKED_NAMESPACE
349 };
350 let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
351
352 previous_paths.remove(&path_key);
353 let load_buffer = self
354 .project
355 .update(cx, |project, cx| project.open_buffer(project_path, cx));
356
357 let project = self.project.clone();
358 result.push(cx.spawn(|_, mut cx| async move {
359 let buffer = load_buffer.await?;
360 let changes = project
361 .update(&mut cx, |project, cx| {
362 project.open_uncommitted_diff(buffer.clone(), cx)
363 })?
364 .await?;
365 Ok(DiffBuffer {
366 path_key,
367 buffer,
368 diff: changes,
369 file_status: entry.status,
370 })
371 }));
372 }
373 });
374 self.multibuffer.update(cx, |multibuffer, cx| {
375 for path in previous_paths {
376 multibuffer.remove_excerpts_for_path(path, cx);
377 }
378 });
379 result
380 }
381
382 fn register_buffer(
383 &mut self,
384 diff_buffer: DiffBuffer,
385 window: &mut Window,
386 cx: &mut Context<Self>,
387 ) {
388 let path_key = diff_buffer.path_key;
389 let buffer = diff_buffer.buffer;
390 let diff = diff_buffer.diff;
391
392 let snapshot = buffer.read(cx).snapshot();
393 let diff = diff.read(cx);
394 let diff_hunk_ranges = diff
395 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
396 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
397 .collect::<Vec<_>>();
398
399 let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| {
400 let was_empty = multibuffer.is_empty();
401 let is_newly_added = multibuffer.set_excerpts_for_path(
402 path_key.clone(),
403 buffer,
404 diff_hunk_ranges,
405 editor::DEFAULT_MULTIBUFFER_CONTEXT,
406 cx,
407 );
408 (was_empty, is_newly_added)
409 });
410
411 self.editor.update(cx, |editor, cx| {
412 if was_empty {
413 editor.change_selections(None, window, cx, |selections| {
414 // TODO select the very beginning (possibly inside a deletion)
415 selections.select_ranges([0..0])
416 });
417 }
418 if is_excerpt_newly_added && diff_buffer.file_status.is_deleted() {
419 editor.fold_buffer(snapshot.text.remote_id(), cx)
420 }
421 });
422
423 if self.multibuffer.read(cx).is_empty()
424 && self
425 .editor
426 .read(cx)
427 .focus_handle(cx)
428 .contains_focused(window, cx)
429 {
430 self.focus_handle.focus(window);
431 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
432 self.editor.update(cx, |editor, cx| {
433 editor.focus_handle(cx).focus(window);
434 });
435 }
436 if self.pending_scroll.as_ref() == Some(&path_key) {
437 self.move_to_path(path_key, window, cx);
438 }
439 }
440
441 pub async fn handle_status_updates(
442 this: WeakEntity<Self>,
443 mut recv: postage::watch::Receiver<()>,
444 mut cx: AsyncWindowContext,
445 ) -> Result<()> {
446 while let Some(_) = recv.next().await {
447 let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
448 for buffer_to_load in buffers_to_load {
449 if let Some(buffer) = buffer_to_load.await.log_err() {
450 cx.update(|window, cx| {
451 this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
452 .ok();
453 })?;
454 }
455 }
456 this.update(&mut cx, |this, _| this.pending_scroll.take())?;
457 }
458
459 Ok(())
460 }
461
462 #[cfg(any(test, feature = "test-support"))]
463 pub fn excerpt_paths(&self, cx: &App) -> Vec<String> {
464 self.multibuffer
465 .read(cx)
466 .excerpt_paths()
467 .map(|key| key.path().to_string_lossy().to_string())
468 .collect()
469 }
470}
471
472impl EventEmitter<EditorEvent> for ProjectDiff {}
473
474impl Focusable for ProjectDiff {
475 fn focus_handle(&self, cx: &App) -> FocusHandle {
476 if self.multibuffer.read(cx).is_empty() {
477 self.focus_handle.clone()
478 } else {
479 self.editor.focus_handle(cx)
480 }
481 }
482}
483
484impl Item for ProjectDiff {
485 type Event = EditorEvent;
486
487 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
488 Some(Icon::new(IconName::GitBranch).color(Color::Muted))
489 }
490
491 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
492 Editor::to_item_events(event, f)
493 }
494
495 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
496 self.editor
497 .update(cx, |editor, cx| editor.deactivated(window, cx));
498 }
499
500 fn navigate(
501 &mut self,
502 data: Box<dyn Any>,
503 window: &mut Window,
504 cx: &mut Context<Self>,
505 ) -> bool {
506 self.editor
507 .update(cx, |editor, cx| editor.navigate(data, window, cx))
508 }
509
510 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
511 Some("Project Diff".into())
512 }
513
514 fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
515 Label::new("Uncommitted Changes")
516 .color(if params.selected {
517 Color::Default
518 } else {
519 Color::Muted
520 })
521 .into_any_element()
522 }
523
524 fn telemetry_event_text(&self) -> Option<&'static str> {
525 Some("Project Diff Opened")
526 }
527
528 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
529 Some(Box::new(self.editor.clone()))
530 }
531
532 fn for_each_project_item(
533 &self,
534 cx: &App,
535 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
536 ) {
537 self.editor.for_each_project_item(cx, f)
538 }
539
540 fn is_singleton(&self, _: &App) -> bool {
541 false
542 }
543
544 fn set_nav_history(
545 &mut self,
546 nav_history: ItemNavHistory,
547 _: &mut Window,
548 cx: &mut Context<Self>,
549 ) {
550 self.editor.update(cx, |editor, _| {
551 editor.set_nav_history(Some(nav_history));
552 });
553 }
554
555 fn clone_on_split(
556 &self,
557 _workspace_id: Option<workspace::WorkspaceId>,
558 window: &mut Window,
559 cx: &mut Context<Self>,
560 ) -> Option<Entity<Self>>
561 where
562 Self: Sized,
563 {
564 let workspace = self.workspace.upgrade()?;
565 Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx)))
566 }
567
568 fn is_dirty(&self, cx: &App) -> bool {
569 self.multibuffer.read(cx).is_dirty(cx)
570 }
571
572 fn has_conflict(&self, cx: &App) -> bool {
573 self.multibuffer.read(cx).has_conflict(cx)
574 }
575
576 fn can_save(&self, _: &App) -> bool {
577 true
578 }
579
580 fn save(
581 &mut self,
582 format: bool,
583 project: Entity<Project>,
584 window: &mut Window,
585 cx: &mut Context<Self>,
586 ) -> Task<Result<()>> {
587 self.editor.save(format, project, window, cx)
588 }
589
590 fn save_as(
591 &mut self,
592 _: Entity<Project>,
593 _: ProjectPath,
594 _window: &mut Window,
595 _: &mut Context<Self>,
596 ) -> Task<Result<()>> {
597 unreachable!()
598 }
599
600 fn reload(
601 &mut self,
602 project: Entity<Project>,
603 window: &mut Window,
604 cx: &mut Context<Self>,
605 ) -> Task<Result<()>> {
606 self.editor.reload(project, window, cx)
607 }
608
609 fn act_as_type<'a>(
610 &'a self,
611 type_id: TypeId,
612 self_handle: &'a Entity<Self>,
613 _: &'a App,
614 ) -> Option<AnyView> {
615 if type_id == TypeId::of::<Self>() {
616 Some(self_handle.to_any())
617 } else if type_id == TypeId::of::<Editor>() {
618 Some(self.editor.to_any())
619 } else {
620 None
621 }
622 }
623
624 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
625 ToolbarItemLocation::PrimaryLeft
626 }
627
628 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
629 self.editor.breadcrumbs(theme, cx)
630 }
631
632 fn added_to_workspace(
633 &mut self,
634 workspace: &mut Workspace,
635 window: &mut Window,
636 cx: &mut Context<Self>,
637 ) {
638 self.editor.update(cx, |editor, cx| {
639 editor.added_to_workspace(workspace, window, cx)
640 });
641 }
642}
643
644impl Render for ProjectDiff {
645 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
646 let is_empty = self.multibuffer.read(cx).is_empty();
647
648 div()
649 .track_focus(&self.focus_handle)
650 .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
651 .bg(cx.theme().colors().editor_background)
652 .flex()
653 .items_center()
654 .justify_center()
655 .size_full()
656 .when(is_empty, |el| {
657 el.child(Label::new("No uncommitted changes"))
658 })
659 .when(!is_empty, |el| el.child(self.editor.clone()))
660 }
661}
662
663impl SerializableItem for ProjectDiff {
664 fn serialized_item_kind() -> &'static str {
665 "ProjectDiff"
666 }
667
668 fn cleanup(
669 _: workspace::WorkspaceId,
670 _: Vec<workspace::ItemId>,
671 _: &mut Window,
672 _: &mut App,
673 ) -> Task<Result<()>> {
674 Task::ready(Ok(()))
675 }
676
677 fn deserialize(
678 _project: Entity<Project>,
679 workspace: WeakEntity<Workspace>,
680 _workspace_id: workspace::WorkspaceId,
681 _item_id: workspace::ItemId,
682 window: &mut Window,
683 cx: &mut App,
684 ) -> Task<Result<Entity<Self>>> {
685 window.spawn(cx, |mut cx| async move {
686 workspace.update_in(&mut cx, |workspace, window, cx| {
687 let workspace_handle = cx.entity();
688 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
689 })
690 })
691 }
692
693 fn serialize(
694 &mut self,
695 _workspace: &mut Workspace,
696 _item_id: workspace::ItemId,
697 _closing: bool,
698 _window: &mut Window,
699 _cx: &mut Context<Self>,
700 ) -> Option<Task<Result<()>>> {
701 None
702 }
703
704 fn should_serialize(&self, _: &Self::Event) -> bool {
705 false
706 }
707}
708
709pub struct ProjectDiffToolbar {
710 project_diff: Option<WeakEntity<ProjectDiff>>,
711 workspace: WeakEntity<Workspace>,
712}
713
714impl ProjectDiffToolbar {
715 pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
716 Self {
717 project_diff: None,
718 workspace: workspace.weak_handle(),
719 }
720 }
721
722 fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
723 self.project_diff.as_ref()?.upgrade()
724 }
725 fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
726 if let Some(project_diff) = self.project_diff(cx) {
727 project_diff.focus_handle(cx).focus(window);
728 }
729 let action = action.boxed_clone();
730 cx.defer(move |cx| {
731 cx.dispatch_action(action.as_ref());
732 })
733 }
734 fn dispatch_panel_action(
735 &self,
736 action: &dyn Action,
737 window: &mut Window,
738 cx: &mut Context<Self>,
739 ) {
740 self.workspace
741 .read_with(cx, |workspace, cx| {
742 if let Some(panel) = workspace.panel::<GitPanel>(cx) {
743 panel.focus_handle(cx).focus(window)
744 }
745 })
746 .ok();
747 let action = action.boxed_clone();
748 cx.defer(move |cx| {
749 cx.dispatch_action(action.as_ref());
750 })
751 }
752}
753
754impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
755
756impl ToolbarItemView for ProjectDiffToolbar {
757 fn set_active_pane_item(
758 &mut self,
759 active_pane_item: Option<&dyn ItemHandle>,
760 _: &mut Window,
761 cx: &mut Context<Self>,
762 ) -> ToolbarItemLocation {
763 self.project_diff = active_pane_item
764 .and_then(|item| item.act_as::<ProjectDiff>(cx))
765 .map(|entity| entity.downgrade());
766 if self.project_diff.is_some() {
767 ToolbarItemLocation::PrimaryRight
768 } else {
769 ToolbarItemLocation::Hidden
770 }
771 }
772
773 fn pane_focus_update(
774 &mut self,
775 _pane_focused: bool,
776 _window: &mut Window,
777 _cx: &mut Context<Self>,
778 ) {
779 }
780}
781
782struct ButtonStates {
783 stage: bool,
784 unstage: bool,
785 prev_next: bool,
786 selection: bool,
787 stage_all: bool,
788 unstage_all: bool,
789}
790
791impl Render for ProjectDiffToolbar {
792 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
793 let Some(project_diff) = self.project_diff(cx) else {
794 return div();
795 };
796 let focus_handle = project_diff.focus_handle(cx);
797 let button_states = project_diff.read(cx).button_states(cx);
798
799 h_group_xl()
800 .my_neg_1()
801 .items_center()
802 .py_1()
803 .pl_2()
804 .pr_1()
805 .flex_wrap()
806 .justify_between()
807 .child(
808 h_group_sm()
809 .when(button_states.selection, |el| {
810 el.child(
811 Button::new("stage", "Toggle Staged")
812 .tooltip(Tooltip::for_action_title_in(
813 "Toggle Staged",
814 &ToggleStaged,
815 &focus_handle,
816 ))
817 .disabled(!button_states.stage && !button_states.unstage)
818 .on_click(cx.listener(|this, _, window, cx| {
819 this.dispatch_action(&ToggleStaged, window, cx)
820 })),
821 )
822 })
823 .when(!button_states.selection, |el| {
824 el.child(
825 Button::new("stage", "Stage")
826 .tooltip(Tooltip::for_action_title_in(
827 "Stage and go to next hunk",
828 &StageAndNext,
829 &focus_handle,
830 ))
831 // don't actually disable the button so it's mashable
832 .color(if button_states.stage {
833 Color::Default
834 } else {
835 Color::Disabled
836 })
837 .on_click(cx.listener(|this, _, window, cx| {
838 this.dispatch_action(&StageAndNext, window, cx)
839 })),
840 )
841 .child(
842 Button::new("unstage", "Unstage")
843 .tooltip(Tooltip::for_action_title_in(
844 "Unstage and go to next hunk",
845 &UnstageAndNext,
846 &focus_handle,
847 ))
848 .color(if button_states.unstage {
849 Color::Default
850 } else {
851 Color::Disabled
852 })
853 .on_click(cx.listener(|this, _, window, cx| {
854 this.dispatch_action(&UnstageAndNext, window, cx)
855 })),
856 )
857 }),
858 )
859 // n.b. the only reason these arrows are here is because we don't
860 // support "undo" for staging so we need a way to go back.
861 .child(
862 h_group_sm()
863 .child(
864 IconButton::new("up", IconName::ArrowUp)
865 .shape(ui::IconButtonShape::Square)
866 .tooltip(Tooltip::for_action_title_in(
867 "Go to previous hunk",
868 &GoToPreviousHunk,
869 &focus_handle,
870 ))
871 .disabled(!button_states.prev_next)
872 .on_click(cx.listener(|this, _, window, cx| {
873 this.dispatch_action(&GoToPreviousHunk, window, cx)
874 })),
875 )
876 .child(
877 IconButton::new("down", IconName::ArrowDown)
878 .shape(ui::IconButtonShape::Square)
879 .tooltip(Tooltip::for_action_title_in(
880 "Go to next hunk",
881 &GoToHunk,
882 &focus_handle,
883 ))
884 .disabled(!button_states.prev_next)
885 .on_click(cx.listener(|this, _, window, cx| {
886 this.dispatch_action(&GoToHunk, window, cx)
887 })),
888 ),
889 )
890 .child(vertical_divider())
891 .child(
892 h_group_sm()
893 .when(
894 button_states.unstage_all && !button_states.stage_all,
895 |el| {
896 el.child(
897 Button::new("unstage-all", "Unstage All")
898 .tooltip(Tooltip::for_action_title_in(
899 "Unstage all changes",
900 &UnstageAll,
901 &focus_handle,
902 ))
903 .on_click(cx.listener(|this, _, window, cx| {
904 this.dispatch_panel_action(&UnstageAll, window, cx)
905 })),
906 )
907 },
908 )
909 .when(
910 !button_states.unstage_all || button_states.stage_all,
911 |el| {
912 el.child(
913 // todo make it so that changing to say "Unstaged"
914 // doesn't change the position.
915 div().child(
916 Button::new("stage-all", "Stage All")
917 .disabled(!button_states.stage_all)
918 .tooltip(Tooltip::for_action_title_in(
919 "Stage all changes",
920 &StageAll,
921 &focus_handle,
922 ))
923 .on_click(cx.listener(|this, _, window, cx| {
924 this.dispatch_panel_action(&StageAll, window, cx)
925 })),
926 ),
927 )
928 },
929 )
930 .child(
931 Button::new("commit", "Commit")
932 .tooltip(Tooltip::for_action_title_in(
933 "Commit",
934 &Commit,
935 &focus_handle,
936 ))
937 .on_click(cx.listener(|this, _, window, cx| {
938 this.dispatch_action(&Commit, window, cx);
939 })),
940 ),
941 )
942 }
943}
944
945#[cfg(not(target_os = "windows"))]
946#[cfg(test)]
947mod tests {
948 use std::path::Path;
949
950 use collections::HashMap;
951 use db::indoc;
952 use editor::test::editor_test_context::{assert_state_with_diff, EditorTestContext};
953 use git::status::{StatusCode, TrackedStatus};
954 use gpui::TestAppContext;
955 use project::FakeFs;
956 use serde_json::json;
957 use settings::SettingsStore;
958 use unindent::Unindent as _;
959 use util::path;
960
961 use super::*;
962
963 #[ctor::ctor]
964 fn init_logger() {
965 env_logger::init();
966 }
967
968 fn init_test(cx: &mut TestAppContext) {
969 cx.update(|cx| {
970 let store = SettingsStore::test(cx);
971 cx.set_global(store);
972 theme::init(theme::LoadThemes::JustBase, cx);
973 language::init(cx);
974 Project::init_settings(cx);
975 workspace::init_settings(cx);
976 editor::init(cx);
977 crate::init(cx);
978 });
979 }
980
981 #[gpui::test]
982 async fn test_save_after_restore(cx: &mut TestAppContext) {
983 init_test(cx);
984
985 let fs = FakeFs::new(cx.executor());
986 fs.insert_tree(
987 path!("/project"),
988 json!({
989 ".git": {},
990 "foo.txt": "FOO\n",
991 }),
992 )
993 .await;
994 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
995 let (workspace, cx) =
996 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
997 let diff = cx.new_window_entity(|window, cx| {
998 ProjectDiff::new(project.clone(), workspace, window, cx)
999 });
1000 cx.run_until_parked();
1001
1002 fs.set_head_for_repo(
1003 path!("/project/.git").as_ref(),
1004 &[("foo.txt".into(), "foo\n".into())],
1005 );
1006 fs.set_index_for_repo(
1007 path!("/project/.git").as_ref(),
1008 &[("foo.txt".into(), "foo\n".into())],
1009 );
1010 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1011 state.statuses = HashMap::from_iter([(
1012 "foo.txt".into(),
1013 TrackedStatus {
1014 index_status: StatusCode::Unmodified,
1015 worktree_status: StatusCode::Modified,
1016 }
1017 .into(),
1018 )]);
1019 });
1020 cx.run_until_parked();
1021
1022 let editor = diff.update(cx, |diff, _| diff.editor.clone());
1023 assert_state_with_diff(
1024 &editor,
1025 cx,
1026 &"
1027 - foo
1028 + ˇFOO
1029 "
1030 .unindent(),
1031 );
1032
1033 editor.update_in(cx, |editor, window, cx| {
1034 editor.git_restore(&Default::default(), window, cx);
1035 });
1036 cx.run_until_parked();
1037
1038 assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1039
1040 let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1041 assert_eq!(text, "foo\n");
1042 }
1043
1044 #[gpui::test]
1045 async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1046 init_test(cx);
1047
1048 let fs = FakeFs::new(cx.executor());
1049 fs.insert_tree(
1050 path!("/project"),
1051 json!({
1052 ".git": {},
1053 "bar": "BAR\n",
1054 "foo": "FOO\n",
1055 }),
1056 )
1057 .await;
1058 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1059 let (workspace, cx) =
1060 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1061 let diff = cx.new_window_entity(|window, cx| {
1062 ProjectDiff::new(project.clone(), workspace, window, cx)
1063 });
1064 cx.run_until_parked();
1065
1066 fs.set_head_for_repo(
1067 path!("/project/.git").as_ref(),
1068 &[
1069 ("bar".into(), "bar\n".into()),
1070 ("foo".into(), "foo\n".into()),
1071 ],
1072 );
1073 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1074 state.statuses = HashMap::from_iter([
1075 (
1076 "bar".into(),
1077 TrackedStatus {
1078 index_status: StatusCode::Unmodified,
1079 worktree_status: StatusCode::Modified,
1080 }
1081 .into(),
1082 ),
1083 (
1084 "foo".into(),
1085 TrackedStatus {
1086 index_status: StatusCode::Unmodified,
1087 worktree_status: StatusCode::Modified,
1088 }
1089 .into(),
1090 ),
1091 ]);
1092 });
1093 cx.run_until_parked();
1094
1095 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1096 diff.move_to_path(
1097 PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()),
1098 window,
1099 cx,
1100 );
1101 diff.editor.clone()
1102 });
1103 assert_state_with_diff(
1104 &editor,
1105 cx,
1106 &"
1107 - bar
1108 + BAR
1109
1110 - ˇfoo
1111 + FOO
1112 "
1113 .unindent(),
1114 );
1115
1116 let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1117 diff.move_to_path(
1118 PathKey::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()),
1119 window,
1120 cx,
1121 );
1122 diff.editor.clone()
1123 });
1124 assert_state_with_diff(
1125 &editor,
1126 cx,
1127 &"
1128 - ˇbar
1129 + BAR
1130
1131 - foo
1132 + FOO
1133 "
1134 .unindent(),
1135 );
1136 }
1137
1138 #[gpui::test]
1139 async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1140 init_test(cx);
1141
1142 let fs = FakeFs::new(cx.executor());
1143 fs.insert_tree(
1144 path!("/project"),
1145 json!({
1146 ".git": {},
1147 "foo": "modified\n",
1148 }),
1149 )
1150 .await;
1151 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1152 let (workspace, cx) =
1153 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1154 let buffer = project
1155 .update(cx, |project, cx| {
1156 project.open_local_buffer(path!("/project/foo"), cx)
1157 })
1158 .await
1159 .unwrap();
1160 let buffer_editor = cx.new_window_entity(|window, cx| {
1161 Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1162 });
1163 let diff = cx.new_window_entity(|window, cx| {
1164 ProjectDiff::new(project.clone(), workspace, window, cx)
1165 });
1166 cx.run_until_parked();
1167
1168 fs.set_head_for_repo(
1169 path!("/project/.git").as_ref(),
1170 &[("foo".into(), "original\n".into())],
1171 );
1172 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1173 state.statuses = HashMap::from_iter([(
1174 "foo".into(),
1175 TrackedStatus {
1176 index_status: StatusCode::Unmodified,
1177 worktree_status: StatusCode::Modified,
1178 }
1179 .into(),
1180 )]);
1181 });
1182 cx.run_until_parked();
1183
1184 let diff_editor = diff.update(cx, |diff, _| diff.editor.clone());
1185
1186 assert_state_with_diff(
1187 &diff_editor,
1188 cx,
1189 &"
1190 - original
1191 + ˇmodified
1192 "
1193 .unindent(),
1194 );
1195
1196 let prev_buffer_hunks =
1197 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1198 let snapshot = buffer_editor.snapshot(window, cx);
1199 let snapshot = &snapshot.buffer_snapshot;
1200 let prev_buffer_hunks = buffer_editor
1201 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1202 .collect::<Vec<_>>();
1203 buffer_editor.git_restore(&Default::default(), window, cx);
1204 prev_buffer_hunks
1205 });
1206 assert_eq!(prev_buffer_hunks.len(), 1);
1207 cx.run_until_parked();
1208
1209 let new_buffer_hunks =
1210 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1211 let snapshot = buffer_editor.snapshot(window, cx);
1212 let snapshot = &snapshot.buffer_snapshot;
1213 let new_buffer_hunks = buffer_editor
1214 .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1215 .collect::<Vec<_>>();
1216 buffer_editor.git_restore(&Default::default(), window, cx);
1217 new_buffer_hunks
1218 });
1219 assert_eq!(new_buffer_hunks.as_slice(), &[]);
1220
1221 cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1222 buffer_editor.set_text("different\n", window, cx);
1223 buffer_editor.save(false, project.clone(), window, cx)
1224 })
1225 .await
1226 .unwrap();
1227
1228 cx.run_until_parked();
1229
1230 assert_state_with_diff(
1231 &diff_editor,
1232 cx,
1233 &"
1234 - original
1235 + ˇdifferent
1236 "
1237 .unindent(),
1238 );
1239 }
1240
1241 use crate::project_diff::{self, ProjectDiff};
1242
1243 #[gpui::test]
1244 async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
1245 init_test(cx);
1246
1247 let fs = FakeFs::new(cx.executor());
1248 fs.insert_tree(
1249 "/a",
1250 json!({
1251 ".git":{},
1252 "a.txt": "created\n",
1253 "b.txt": "really changed\n",
1254 "c.txt": "unchanged\n"
1255 }),
1256 )
1257 .await;
1258
1259 fs.set_git_content_for_repo(
1260 Path::new("/a/.git"),
1261 &[
1262 ("b.txt".into(), "before\n".to_string(), None),
1263 ("c.txt".into(), "unchanged\n".to_string(), None),
1264 ("d.txt".into(), "deleted\n".to_string(), None),
1265 ],
1266 );
1267
1268 let project = Project::test(fs, [Path::new("/a")], cx).await;
1269 let (workspace, cx) =
1270 cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1271
1272 cx.run_until_parked();
1273
1274 cx.focus(&workspace);
1275 cx.update(|window, cx| {
1276 window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1277 });
1278
1279 cx.run_until_parked();
1280
1281 let item = workspace.update(cx, |workspace, cx| {
1282 workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1283 });
1284 cx.focus(&item);
1285 let editor = item.update(cx, |item, _| item.editor.clone());
1286
1287 let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1288
1289 cx.assert_excerpts_with_selections(indoc!(
1290 "
1291 [EXCERPT]
1292 before
1293 really changed
1294 [EXCERPT]
1295 [FOLDED]
1296 [EXCERPT]
1297 ˇcreated
1298 "
1299 ));
1300
1301 cx.dispatch_action(editor::actions::GoToPreviousHunk);
1302
1303 cx.assert_excerpts_with_selections(indoc!(
1304 "
1305 [EXCERPT]
1306 before
1307 really changed
1308 [EXCERPT]
1309 ˇ[FOLDED]
1310 [EXCERPT]
1311 created
1312 "
1313 ));
1314
1315 cx.dispatch_action(editor::actions::GoToPreviousHunk);
1316
1317 cx.assert_excerpts_with_selections(indoc!(
1318 "
1319 [EXCERPT]
1320 ˇbefore
1321 really changed
1322 [EXCERPT]
1323 [FOLDED]
1324 [EXCERPT]
1325 created
1326 "
1327 ));
1328 }
1329}