git_ui.rs

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