git_ui.rs

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