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