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