git_ui.rs

  1use std::any::Any;
  2
  3use ::settings::Settings;
  4use command_palette_hooks::CommandPaletteFilter;
  5use git::{
  6    repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
  7    status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
  8};
  9use git_panel_settings::GitPanelSettings;
 10use gpui::{actions, App, FocusHandle};
 11use onboarding::{clear_dismissed, GitOnboardingModal};
 12use project_diff::ProjectDiff;
 13use ui::prelude::*;
 14use workspace::Workspace;
 15
 16mod askpass_modal;
 17pub mod branch_picker;
 18mod commit_modal;
 19pub mod git_panel;
 20mod git_panel_settings;
 21pub mod onboarding;
 22pub mod picker_prompt;
 23pub mod project_diff;
 24pub(crate) mod remote_output;
 25pub mod repository_selector;
 26
 27actions!(git, [ResetOnboarding]);
 28
 29pub fn init(cx: &mut App) {
 30    GitPanelSettings::register(cx);
 31    branch_picker::init(cx);
 32    cx.observe_new(ProjectDiff::register).detach();
 33    commit_modal::init(cx);
 34    git_panel::init(cx);
 35
 36    cx.observe_new(|workspace: &mut Workspace, _, cx| {
 37        let project = workspace.project().read(cx);
 38        if project.is_read_only(cx) {
 39            return;
 40        }
 41        if !project.is_via_collab() {
 42            workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
 43                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 44                    return;
 45                };
 46                panel.update(cx, |panel, cx| {
 47                    panel.fetch(window, cx);
 48                });
 49            });
 50            workspace.register_action(|workspace, _: &git::Push, window, cx| {
 51                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 52                    return;
 53                };
 54                panel.update(cx, |panel, cx| {
 55                    panel.push(false, window, cx);
 56                });
 57            });
 58            workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
 59                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 60                    return;
 61                };
 62                panel.update(cx, |panel, cx| {
 63                    panel.push(true, window, cx);
 64                });
 65            });
 66            workspace.register_action(|workspace, _: &git::Pull, window, cx| {
 67                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 68                    return;
 69                };
 70                panel.update(cx, |panel, cx| {
 71                    panel.pull(window, cx);
 72                });
 73            });
 74        }
 75        workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
 76            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 77                return;
 78            };
 79            panel.update(cx, |panel, cx| {
 80                panel.stage_all(action, window, cx);
 81            });
 82        });
 83        workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
 84            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 85                return;
 86            };
 87            panel.update(cx, |panel, cx| {
 88                panel.unstage_all(action, window, cx);
 89            });
 90        });
 91        CommandPaletteFilter::update_global(cx, |filter, _cx| {
 92            filter.hide_action_types(&[
 93                zed_actions::OpenGitIntegrationOnboarding.type_id(),
 94                // ResetOnboarding.type_id(),
 95            ]);
 96        });
 97        workspace.register_action(
 98            move |workspace, _: &zed_actions::OpenGitIntegrationOnboarding, window, cx| {
 99                GitOnboardingModal::toggle(workspace, window, cx)
100            },
101        );
102        workspace.register_action(move |_, _: &ResetOnboarding, window, cx| {
103            clear_dismissed(cx);
104            window.refresh();
105        });
106        workspace.register_action(|workspace, _action: &git::Init, window, cx| {
107            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
108                return;
109            };
110            panel.update(cx, |panel, cx| {
111                panel.git_init(window, cx);
112            });
113        });
114    })
115    .detach();
116}
117
118pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
119    GitStatusIcon::new(status)
120}
121
122fn render_remote_button(
123    id: impl Into<SharedString>,
124    branch: &Branch,
125    keybinding_target: Option<FocusHandle>,
126    show_fetch_button: bool,
127) -> Option<impl IntoElement> {
128    let id = id.into();
129    let upstream = branch.upstream.as_ref();
130    match upstream {
131        Some(Upstream {
132            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
133            ..
134        }) => match (*ahead, *behind) {
135            (0, 0) if show_fetch_button => {
136                Some(remote_button::render_fetch_button(keybinding_target, id))
137            }
138            (0, 0) => None,
139            (ahead, 0) => Some(remote_button::render_push_button(
140                keybinding_target.clone(),
141                id,
142                ahead,
143            )),
144            (ahead, behind) => Some(remote_button::render_pull_button(
145                keybinding_target.clone(),
146                id,
147                ahead,
148                behind,
149            )),
150        },
151        Some(Upstream {
152            tracking: UpstreamTracking::Gone,
153            ..
154        }) => Some(remote_button::render_republish_button(
155            keybinding_target,
156            id,
157        )),
158        None => Some(remote_button::render_publish_button(keybinding_target, id)),
159    }
160}
161
162mod remote_button {
163    use gpui::{hsla, point, Action, AnyView, BoxShadow, ClickEvent, Corner, FocusHandle};
164    use ui::{
165        div, h_flex, px, rems, ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, Clickable,
166        ContextMenu, ElementId, ElevationIndex, FluentBuilder, Icon, IconName, IconSize,
167        IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement, PopoverMenu,
168        RenderOnce, SharedString, Styled, Tooltip, Window,
169    };
170
171    pub fn render_fetch_button(
172        keybinding_target: Option<FocusHandle>,
173        id: SharedString,
174    ) -> SplitButton {
175        SplitButton::new(
176            id,
177            "Fetch",
178            0,
179            0,
180            Some(IconName::ArrowCircle),
181            keybinding_target.clone(),
182            move |_, window, cx| {
183                window.dispatch_action(Box::new(git::Fetch), cx);
184            },
185            move |window, cx| {
186                git_action_tooltip(
187                    "Fetch updates from remote",
188                    &git::Fetch,
189                    "git fetch",
190                    keybinding_target.clone(),
191                    window,
192                    cx,
193                )
194            },
195        )
196    }
197
198    pub fn render_push_button(
199        keybinding_target: Option<FocusHandle>,
200        id: SharedString,
201        ahead: u32,
202    ) -> SplitButton {
203        SplitButton::new(
204            id,
205            "Push",
206            ahead as usize,
207            0,
208            None,
209            keybinding_target.clone(),
210            move |_, window, cx| {
211                window.dispatch_action(Box::new(git::Push), cx);
212            },
213            move |window, cx| {
214                git_action_tooltip(
215                    "Push committed changes to remote",
216                    &git::Push,
217                    "git push",
218                    keybinding_target.clone(),
219                    window,
220                    cx,
221                )
222            },
223        )
224    }
225
226    pub fn render_pull_button(
227        keybinding_target: Option<FocusHandle>,
228        id: SharedString,
229        ahead: u32,
230        behind: u32,
231    ) -> SplitButton {
232        SplitButton::new(
233            id,
234            "Pull",
235            ahead as usize,
236            behind as usize,
237            None,
238            keybinding_target.clone(),
239            move |_, window, cx| {
240                window.dispatch_action(Box::new(git::Pull), cx);
241            },
242            move |window, cx| {
243                git_action_tooltip(
244                    "Pull",
245                    &git::Pull,
246                    "git pull",
247                    keybinding_target.clone(),
248                    window,
249                    cx,
250                )
251            },
252        )
253    }
254
255    pub fn render_publish_button(
256        keybinding_target: Option<FocusHandle>,
257        id: SharedString,
258    ) -> SplitButton {
259        SplitButton::new(
260            id,
261            "Publish",
262            0,
263            0,
264            Some(IconName::ArrowUpFromLine),
265            keybinding_target.clone(),
266            move |_, window, cx| {
267                window.dispatch_action(Box::new(git::Push), cx);
268            },
269            move |window, cx| {
270                git_action_tooltip(
271                    "Publish branch to remote",
272                    &git::Push,
273                    "git push --set-upstream",
274                    keybinding_target.clone(),
275                    window,
276                    cx,
277                )
278            },
279        )
280    }
281
282    pub fn render_republish_button(
283        keybinding_target: Option<FocusHandle>,
284        id: SharedString,
285    ) -> SplitButton {
286        SplitButton::new(
287            id,
288            "Republish",
289            0,
290            0,
291            Some(IconName::ArrowUpFromLine),
292            keybinding_target.clone(),
293            move |_, window, cx| {
294                window.dispatch_action(Box::new(git::Push), cx);
295            },
296            move |window, cx| {
297                git_action_tooltip(
298                    "Re-publish branch to remote",
299                    &git::Push,
300                    "git push --set-upstream",
301                    keybinding_target.clone(),
302                    window,
303                    cx,
304                )
305            },
306        )
307    }
308
309    fn git_action_tooltip(
310        label: impl Into<SharedString>,
311        action: &dyn Action,
312        command: impl Into<SharedString>,
313        focus_handle: Option<FocusHandle>,
314        window: &mut Window,
315        cx: &mut App,
316    ) -> AnyView {
317        let label = label.into();
318        let command = command.into();
319
320        if let Some(handle) = focus_handle {
321            Tooltip::with_meta_in(
322                label.clone(),
323                Some(action),
324                command.clone(),
325                &handle,
326                window,
327                cx,
328            )
329        } else {
330            Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
331        }
332    }
333
334    fn render_git_action_menu(
335        id: impl Into<ElementId>,
336        keybinding_target: Option<FocusHandle>,
337    ) -> impl IntoElement {
338        PopoverMenu::new(id.into())
339            .trigger(
340                ui::ButtonLike::new_rounded_right("split-button-right")
341                    .layer(ui::ElevationIndex::ModalSurface)
342                    .size(ui::ButtonSize::None)
343                    .child(
344                        div()
345                            .px_1()
346                            .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
347                    ),
348            )
349            .menu(move |window, cx| {
350                Some(ContextMenu::build(window, cx, |context_menu, _, _| {
351                    context_menu
352                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
353                            el.context(keybinding_target.clone())
354                        })
355                        .action("Fetch", git::Fetch.boxed_clone())
356                        .action("Pull", git::Pull.boxed_clone())
357                        .separator()
358                        .action("Push", git::Push.boxed_clone())
359                        .action("Force Push", git::ForcePush.boxed_clone())
360                }))
361            })
362            .anchor(Corner::TopRight)
363    }
364
365    #[derive(IntoElement)]
366    pub struct SplitButton {
367        pub left: ButtonLike,
368        pub right: AnyElement,
369    }
370
371    impl SplitButton {
372        #[allow(clippy::too_many_arguments)]
373        fn new(
374            id: impl Into<SharedString>,
375            left_label: impl Into<SharedString>,
376            ahead_count: usize,
377            behind_count: usize,
378            left_icon: Option<IconName>,
379            keybinding_target: Option<FocusHandle>,
380            left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
381            tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
382        ) -> Self {
383            let id = id.into();
384
385            fn count(count: usize) -> impl IntoElement {
386                h_flex()
387                    .ml_neg_px()
388                    .h(rems(0.875))
389                    .items_center()
390                    .overflow_hidden()
391                    .px_0p5()
392                    .child(
393                        Label::new(count.to_string())
394                            .size(LabelSize::XSmall)
395                            .line_height_style(LineHeightStyle::UiLabel),
396                    )
397            }
398
399            let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
400
401            let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
402                format!("split-button-left-{}", id).into(),
403            ))
404            .layer(ui::ElevationIndex::ModalSurface)
405            .size(ui::ButtonSize::Compact)
406            .when(should_render_counts, |this| {
407                this.child(
408                    h_flex()
409                        .ml_neg_0p5()
410                        .mr_1()
411                        .when(behind_count > 0, |this| {
412                            this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
413                                .child(count(behind_count))
414                        })
415                        .when(ahead_count > 0, |this| {
416                            this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
417                                .child(count(ahead_count))
418                        }),
419                )
420            })
421            .when_some(left_icon, |this, left_icon| {
422                this.child(
423                    h_flex()
424                        .ml_neg_0p5()
425                        .mr_1()
426                        .child(Icon::new(left_icon).size(IconSize::XSmall)),
427                )
428            })
429            .child(
430                div()
431                    .child(Label::new(left_label).size(LabelSize::Small))
432                    .mr_0p5(),
433            )
434            .on_click(left_on_click)
435            .tooltip(tooltip);
436
437            let right = render_git_action_menu(
438                ElementId::Name(format!("split-button-right-{}", id).into()),
439                keybinding_target,
440            )
441            .into_any_element();
442
443            Self { left, right }
444        }
445    }
446
447    impl RenderOnce for SplitButton {
448        fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
449            h_flex()
450                .rounded_sm()
451                .border_1()
452                .border_color(cx.theme().colors().text_muted.alpha(0.12))
453                .child(div().flex_grow().child(self.left))
454                .child(
455                    div()
456                        .h_full()
457                        .w_px()
458                        .bg(cx.theme().colors().text_muted.alpha(0.16)),
459                )
460                .child(self.right)
461                .bg(ElevationIndex::Surface.on_elevation_bg(cx))
462                .shadow(smallvec::smallvec![BoxShadow {
463                    color: hsla(0.0, 0.0, 0.0, 0.16),
464                    offset: point(px(0.), px(1.)),
465                    blur_radius: px(0.),
466                    spread_radius: px(0.),
467                }])
468        }
469    }
470}
471
472#[derive(IntoElement, IntoComponent)]
473#[component(scope = "Version Control")]
474pub struct GitStatusIcon {
475    status: FileStatus,
476}
477
478impl GitStatusIcon {
479    pub fn new(status: FileStatus) -> Self {
480        Self { status }
481    }
482}
483
484impl RenderOnce for GitStatusIcon {
485    fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
486        let status = self.status;
487
488        let (icon_name, color) = if status.is_conflicted() {
489            (
490                IconName::Warning,
491                cx.theme().colors().version_control_conflict,
492            )
493        } else if status.is_deleted() {
494            (
495                IconName::SquareMinus,
496                cx.theme().colors().version_control_deleted,
497            )
498        } else if status.is_modified() {
499            (
500                IconName::SquareDot,
501                cx.theme().colors().version_control_modified,
502            )
503        } else {
504            (
505                IconName::SquarePlus,
506                cx.theme().colors().version_control_added,
507            )
508        };
509
510        Icon::new(icon_name).color(Color::Custom(color))
511    }
512}
513
514// View this component preview using `workspace: open component-preview`
515impl ComponentPreview for GitStatusIcon {
516    fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
517        fn tracked_file_status(code: StatusCode) -> FileStatus {
518            FileStatus::Tracked(git::status::TrackedStatus {
519                index_status: code,
520                worktree_status: code,
521            })
522        }
523
524        let modified = tracked_file_status(StatusCode::Modified);
525        let added = tracked_file_status(StatusCode::Added);
526        let deleted = tracked_file_status(StatusCode::Deleted);
527        let conflict = UnmergedStatus {
528            first_head: UnmergedStatusCode::Updated,
529            second_head: UnmergedStatusCode::Updated,
530        }
531        .into();
532
533        v_flex()
534            .gap_6()
535            .children(vec![example_group(vec![
536                single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
537                single_example("Added", GitStatusIcon::new(added).into_any_element()),
538                single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
539                single_example(
540                    "Conflicted",
541                    GitStatusIcon::new(conflict).into_any_element(),
542                ),
543            ])])
544            .into_any_element()
545    }
546}