git_ui.rs

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