git_ui.rs

  1use std::any::Any;
  2
  3use ::settings::Settings;
  4use command_palette_hooks::CommandPaletteFilter;
  5use commit_modal::CommitModal;
  6use editor::{Editor, actions::DiffClipboardWithSelectionData};
  7mod blame_ui;
  8use git::{
  9    repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
 10    status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
 11};
 12use git_panel_settings::GitPanelSettings;
 13use gpui::{
 14    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window,
 15    actions,
 16};
 17use onboarding::GitOnboardingModal;
 18use project_diff::ProjectDiff;
 19use ui::prelude::*;
 20use workspace::{ModalView, Workspace};
 21use zed_actions;
 22
 23use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
 24
 25mod askpass_modal;
 26pub mod branch_picker;
 27mod commit_modal;
 28pub mod commit_tooltip;
 29mod commit_view;
 30mod conflict_view;
 31pub mod file_diff_view;
 32pub mod git_panel;
 33mod git_panel_settings;
 34pub mod onboarding;
 35pub mod picker_prompt;
 36pub mod project_diff;
 37pub(crate) mod remote_output;
 38pub mod repository_selector;
 39pub mod text_diff_view;
 40
 41actions!(
 42    git,
 43    [
 44        /// Resets the git onboarding state to show the tutorial again.
 45        ResetOnboarding
 46    ]
 47);
 48
 49pub fn init(cx: &mut App) {
 50    GitPanelSettings::register(cx);
 51
 52    editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
 53
 54    cx.observe_new(|editor: &mut Editor, _, cx| {
 55        conflict_view::register_editor(editor, editor.buffer().clone(), cx);
 56    })
 57    .detach();
 58
 59    cx.observe_new(|workspace: &mut Workspace, _, cx| {
 60        ProjectDiff::register(workspace, cx);
 61        CommitModal::register(workspace);
 62        git_panel::register(workspace);
 63        repository_selector::register(workspace);
 64        branch_picker::register(workspace);
 65
 66        let project = workspace.project().read(cx);
 67        if project.is_read_only(cx) {
 68            return;
 69        }
 70        if !project.is_via_collab() {
 71            workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
 72                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 73                    return;
 74                };
 75                panel.update(cx, |panel, cx| {
 76                    panel.fetch(true, window, cx);
 77                });
 78            });
 79            workspace.register_action(|workspace, _: &git::FetchFrom, window, cx| {
 80                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 81                    return;
 82                };
 83                panel.update(cx, |panel, cx| {
 84                    panel.fetch(false, window, cx);
 85                });
 86            });
 87            workspace.register_action(|workspace, _: &git::Push, window, cx| {
 88                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 89                    return;
 90                };
 91                panel.update(cx, |panel, cx| {
 92                    panel.push(false, false, window, cx);
 93                });
 94            });
 95            workspace.register_action(|workspace, _: &git::PushTo, window, cx| {
 96                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 97                    return;
 98                };
 99                panel.update(cx, |panel, cx| {
100                    panel.push(false, true, window, cx);
101                });
102            });
103            workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
104                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
105                    return;
106                };
107                panel.update(cx, |panel, cx| {
108                    panel.push(true, false, window, cx);
109                });
110            });
111            workspace.register_action(|workspace, _: &git::Pull, window, cx| {
112                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
113                    return;
114                };
115                panel.update(cx, |panel, cx| {
116                    panel.pull(window, cx);
117                });
118            });
119        }
120        workspace.register_action(|workspace, action: &git::StashAll, window, cx| {
121            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
122                return;
123            };
124            panel.update(cx, |panel, cx| {
125                panel.stash_all(action, window, cx);
126            });
127        });
128        workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
129            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
130                return;
131            };
132            panel.update(cx, |panel, cx| {
133                panel.stash_pop(action, window, cx);
134            });
135        });
136        workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
137            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
138                return;
139            };
140            panel.update(cx, |panel, cx| {
141                panel.stage_all(action, window, cx);
142            });
143        });
144        workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
145            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
146                return;
147            };
148            panel.update(cx, |panel, cx| {
149                panel.unstage_all(action, window, cx);
150            });
151        });
152        CommandPaletteFilter::update_global(cx, |filter, _cx| {
153            filter.hide_action_types(&[
154                zed_actions::OpenGitIntegrationOnboarding.type_id(),
155                // ResetOnboarding.type_id(),
156            ]);
157        });
158        workspace.register_action(
159            move |workspace, _: &zed_actions::OpenGitIntegrationOnboarding, window, cx| {
160                GitOnboardingModal::toggle(workspace, window, cx)
161            },
162        );
163        workspace.register_action(move |_, _: &ResetOnboarding, window, cx| {
164            window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
165            window.refresh();
166        });
167        workspace.register_action(|workspace, _action: &git::Init, window, cx| {
168            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
169                return;
170            };
171            panel.update(cx, |panel, cx| {
172                panel.git_init(window, cx);
173            });
174        });
175        workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
176            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
177                return;
178            };
179
180            workspace.toggle_modal(window, cx, |window, cx| {
181                GitCloneModal::show(panel, window, cx)
182            });
183        });
184        workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
185            open_modified_files(workspace, window, cx);
186        });
187        workspace.register_action(
188            |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
189                if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
190                    task.detach();
191                };
192            },
193        );
194    })
195    .detach();
196}
197
198fn open_modified_files(
199    workspace: &mut Workspace,
200    window: &mut Window,
201    cx: &mut Context<Workspace>,
202) {
203    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
204        return;
205    };
206    let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
207        let Some(repo) = panel.active_repository.as_ref() else {
208            return Vec::new();
209        };
210        let repo = repo.read(cx);
211        repo.cached_status()
212            .filter_map(|entry| {
213                if entry.status.is_modified() {
214                    repo.repo_path_to_project_path(&entry.repo_path, cx)
215                } else {
216                    None
217                }
218            })
219            .collect()
220    });
221    for path in modified_paths {
222        workspace.open_path(path, None, true, window, cx).detach();
223    }
224}
225
226pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
227    GitStatusIcon::new(status)
228}
229
230fn render_remote_button(
231    id: impl Into<SharedString>,
232    branch: &Branch,
233    keybinding_target: Option<FocusHandle>,
234    show_fetch_button: bool,
235) -> Option<impl IntoElement> {
236    let id = id.into();
237    let upstream = branch.upstream.as_ref();
238    match upstream {
239        Some(Upstream {
240            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
241            ..
242        }) => match (*ahead, *behind) {
243            (0, 0) if show_fetch_button => {
244                Some(remote_button::render_fetch_button(keybinding_target, id))
245            }
246            (0, 0) => None,
247            (ahead, 0) => Some(remote_button::render_push_button(
248                keybinding_target.clone(),
249                id,
250                ahead,
251            )),
252            (ahead, behind) => Some(remote_button::render_pull_button(
253                keybinding_target.clone(),
254                id,
255                ahead,
256                behind,
257            )),
258        },
259        Some(Upstream {
260            tracking: UpstreamTracking::Gone,
261            ..
262        }) => Some(remote_button::render_republish_button(
263            keybinding_target,
264            id,
265        )),
266        None => Some(remote_button::render_publish_button(keybinding_target, id)),
267    }
268}
269
270mod remote_button {
271    use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
272    use ui::{
273        App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
274        IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
275        PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
276    };
277
278    pub fn render_fetch_button(
279        keybinding_target: Option<FocusHandle>,
280        id: SharedString,
281    ) -> SplitButton {
282        split_button(
283            id,
284            "Fetch",
285            0,
286            0,
287            Some(IconName::ArrowCircle),
288            keybinding_target.clone(),
289            move |_, window, cx| {
290                window.dispatch_action(Box::new(git::Fetch), cx);
291            },
292            move |window, cx| {
293                git_action_tooltip(
294                    "Fetch updates from remote",
295                    &git::Fetch,
296                    "git fetch",
297                    keybinding_target.clone(),
298                    window,
299                    cx,
300                )
301            },
302        )
303    }
304
305    pub fn render_push_button(
306        keybinding_target: Option<FocusHandle>,
307        id: SharedString,
308        ahead: u32,
309    ) -> SplitButton {
310        split_button(
311            id,
312            "Push",
313            ahead as usize,
314            0,
315            None,
316            keybinding_target.clone(),
317            move |_, window, cx| {
318                window.dispatch_action(Box::new(git::Push), cx);
319            },
320            move |window, cx| {
321                git_action_tooltip(
322                    "Push committed changes to remote",
323                    &git::Push,
324                    "git push",
325                    keybinding_target.clone(),
326                    window,
327                    cx,
328                )
329            },
330        )
331    }
332
333    pub fn render_pull_button(
334        keybinding_target: Option<FocusHandle>,
335        id: SharedString,
336        ahead: u32,
337        behind: u32,
338    ) -> SplitButton {
339        split_button(
340            id,
341            "Pull",
342            ahead as usize,
343            behind as usize,
344            None,
345            keybinding_target.clone(),
346            move |_, window, cx| {
347                window.dispatch_action(Box::new(git::Pull), cx);
348            },
349            move |window, cx| {
350                git_action_tooltip(
351                    "Pull",
352                    &git::Pull,
353                    "git pull",
354                    keybinding_target.clone(),
355                    window,
356                    cx,
357                )
358            },
359        )
360    }
361
362    pub fn render_publish_button(
363        keybinding_target: Option<FocusHandle>,
364        id: SharedString,
365    ) -> SplitButton {
366        split_button(
367            id,
368            "Publish",
369            0,
370            0,
371            Some(IconName::ExpandUp),
372            keybinding_target.clone(),
373            move |_, window, cx| {
374                window.dispatch_action(Box::new(git::Push), cx);
375            },
376            move |window, cx| {
377                git_action_tooltip(
378                    "Publish branch to remote",
379                    &git::Push,
380                    "git push --set-upstream",
381                    keybinding_target.clone(),
382                    window,
383                    cx,
384                )
385            },
386        )
387    }
388
389    pub fn render_republish_button(
390        keybinding_target: Option<FocusHandle>,
391        id: SharedString,
392    ) -> SplitButton {
393        split_button(
394            id,
395            "Republish",
396            0,
397            0,
398            Some(IconName::ExpandUp),
399            keybinding_target.clone(),
400            move |_, window, cx| {
401                window.dispatch_action(Box::new(git::Push), cx);
402            },
403            move |window, cx| {
404                git_action_tooltip(
405                    "Re-publish branch to remote",
406                    &git::Push,
407                    "git push --set-upstream",
408                    keybinding_target.clone(),
409                    window,
410                    cx,
411                )
412            },
413        )
414    }
415
416    fn git_action_tooltip(
417        label: impl Into<SharedString>,
418        action: &dyn Action,
419        command: impl Into<SharedString>,
420        focus_handle: Option<FocusHandle>,
421        window: &mut Window,
422        cx: &mut App,
423    ) -> AnyView {
424        let label = label.into();
425        let command = command.into();
426
427        if let Some(handle) = focus_handle {
428            Tooltip::with_meta_in(
429                label.clone(),
430                Some(action),
431                command.clone(),
432                &handle,
433                window,
434                cx,
435            )
436        } else {
437            Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
438        }
439    }
440
441    fn render_git_action_menu(
442        id: impl Into<ElementId>,
443        keybinding_target: Option<FocusHandle>,
444    ) -> impl IntoElement {
445        PopoverMenu::new(id.into())
446            .trigger(
447                ui::ButtonLike::new_rounded_right("split-button-right")
448                    .layer(ui::ElevationIndex::ModalSurface)
449                    .size(ui::ButtonSize::None)
450                    .child(
451                        div()
452                            .px_1()
453                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
454                    ),
455            )
456            .menu(move |window, cx| {
457                Some(ContextMenu::build(window, cx, |context_menu, _, _| {
458                    context_menu
459                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
460                            el.context(keybinding_target.clone())
461                        })
462                        .action("Fetch", git::Fetch.boxed_clone())
463                        .action("Fetch From", git::FetchFrom.boxed_clone())
464                        .action("Pull", git::Pull.boxed_clone())
465                        .separator()
466                        .action("Push", git::Push.boxed_clone())
467                        .action("Push To", git::PushTo.boxed_clone())
468                        .action("Force Push", git::ForcePush.boxed_clone())
469                }))
470            })
471            .anchor(Corner::TopRight)
472    }
473
474    #[allow(clippy::too_many_arguments)]
475    fn split_button(
476        id: SharedString,
477        left_label: impl Into<SharedString>,
478        ahead_count: usize,
479        behind_count: usize,
480        left_icon: Option<IconName>,
481        keybinding_target: Option<FocusHandle>,
482        left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
483        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
484    ) -> SplitButton {
485        fn count(count: usize) -> impl IntoElement {
486            h_flex()
487                .ml_neg_px()
488                .h(rems(0.875))
489                .items_center()
490                .overflow_hidden()
491                .px_0p5()
492                .child(
493                    Label::new(count.to_string())
494                        .size(LabelSize::XSmall)
495                        .line_height_style(LineHeightStyle::UiLabel),
496                )
497        }
498
499        let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
500
501        let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
502            format!("split-button-left-{}", id).into(),
503        ))
504        .layer(ui::ElevationIndex::ModalSurface)
505        .size(ui::ButtonSize::Compact)
506        .when(should_render_counts, |this| {
507            this.child(
508                h_flex()
509                    .ml_neg_0p5()
510                    .mr_1()
511                    .when(behind_count > 0, |this| {
512                        this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
513                            .child(count(behind_count))
514                    })
515                    .when(ahead_count > 0, |this| {
516                        this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
517                            .child(count(ahead_count))
518                    }),
519            )
520        })
521        .when_some(left_icon, |this, left_icon| {
522            this.child(
523                h_flex()
524                    .ml_neg_0p5()
525                    .mr_1()
526                    .child(Icon::new(left_icon).size(IconSize::XSmall)),
527            )
528        })
529        .child(
530            div()
531                .child(Label::new(left_label).size(LabelSize::Small))
532                .mr_0p5(),
533        )
534        .on_click(left_on_click)
535        .tooltip(tooltip);
536
537        let right = render_git_action_menu(
538            ElementId::Name(format!("split-button-right-{}", id).into()),
539            keybinding_target,
540        )
541        .into_any_element();
542
543        SplitButton::new(left, right)
544    }
545}
546
547/// A visual representation of a file's Git status.
548#[derive(IntoElement, RegisterComponent)]
549pub struct GitStatusIcon {
550    status: FileStatus,
551}
552
553impl GitStatusIcon {
554    pub fn new(status: FileStatus) -> Self {
555        Self { status }
556    }
557}
558
559impl RenderOnce for GitStatusIcon {
560    fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
561        let status = self.status;
562
563        let (icon_name, color) = if status.is_conflicted() {
564            (
565                IconName::Warning,
566                cx.theme().colors().version_control_conflict,
567            )
568        } else if status.is_deleted() {
569            (
570                IconName::SquareMinus,
571                cx.theme().colors().version_control_deleted,
572            )
573        } else if status.is_modified() {
574            (
575                IconName::SquareDot,
576                cx.theme().colors().version_control_modified,
577            )
578        } else {
579            (
580                IconName::SquarePlus,
581                cx.theme().colors().version_control_added,
582            )
583        };
584
585        Icon::new(icon_name).color(Color::Custom(color))
586    }
587}
588
589// View this component preview using `workspace: open component-preview`
590impl Component for GitStatusIcon {
591    fn scope() -> ComponentScope {
592        ComponentScope::VersionControl
593    }
594
595    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
596        fn tracked_file_status(code: StatusCode) -> FileStatus {
597            FileStatus::Tracked(git::status::TrackedStatus {
598                index_status: code,
599                worktree_status: code,
600            })
601        }
602
603        let modified = tracked_file_status(StatusCode::Modified);
604        let added = tracked_file_status(StatusCode::Added);
605        let deleted = tracked_file_status(StatusCode::Deleted);
606        let conflict = UnmergedStatus {
607            first_head: UnmergedStatusCode::Updated,
608            second_head: UnmergedStatusCode::Updated,
609        }
610        .into();
611
612        Some(
613            v_flex()
614                .gap_6()
615                .children(vec![example_group(vec![
616                    single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
617                    single_example("Added", GitStatusIcon::new(added).into_any_element()),
618                    single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
619                    single_example(
620                        "Conflicted",
621                        GitStatusIcon::new(conflict).into_any_element(),
622                    ),
623                ])])
624                .into_any_element(),
625        )
626    }
627}
628
629struct GitCloneModal {
630    panel: Entity<GitPanel>,
631    repo_input: Entity<Editor>,
632    focus_handle: FocusHandle,
633}
634
635impl GitCloneModal {
636    pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
637        let repo_input = cx.new(|cx| {
638            let mut editor = Editor::single_line(window, cx);
639            editor.set_placeholder_text("Enter repository URL…", cx);
640            editor
641        });
642        let focus_handle = repo_input.focus_handle(cx);
643
644        window.focus(&focus_handle);
645
646        Self {
647            panel,
648            repo_input,
649            focus_handle,
650        }
651    }
652}
653
654impl Focusable for GitCloneModal {
655    fn focus_handle(&self, _: &App) -> FocusHandle {
656        self.focus_handle.clone()
657    }
658}
659
660impl Render for GitCloneModal {
661    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
662        div()
663            .elevation_3(cx)
664            .w(rems(34.))
665            .flex_1()
666            .overflow_hidden()
667            .child(
668                div()
669                    .w_full()
670                    .p_2()
671                    .border_b_1()
672                    .border_color(cx.theme().colors().border_variant)
673                    .child(self.repo_input.clone()),
674            )
675            .child(
676                h_flex()
677                    .w_full()
678                    .p_2()
679                    .gap_0p5()
680                    .rounded_b_sm()
681                    .bg(cx.theme().colors().editor_background)
682                    .child(
683                        Label::new("Clone a repository from GitHub or other sources.")
684                            .color(Color::Muted)
685                            .size(LabelSize::Small),
686                    )
687                    .child(
688                        Button::new("learn-more", "Learn More")
689                            .label_size(LabelSize::Small)
690                            .icon(IconName::ArrowUpRight)
691                            .icon_size(IconSize::XSmall)
692                            .on_click(|_, _, cx| {
693                                cx.open_url("https://github.com/git-guides/git-clone");
694                            }),
695                    ),
696            )
697            .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
698                cx.emit(DismissEvent);
699            }))
700            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
701                let repo = this.repo_input.read(cx).text(cx);
702                this.panel.update(cx, |panel, cx| {
703                    panel.git_clone(repo, window, cx);
704                });
705                cx.emit(DismissEvent);
706            }))
707    }
708}
709
710impl EventEmitter<DismissEvent> for GitCloneModal {}
711
712impl ModalView for GitCloneModal {}