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
372    #[allow(clippy::too_many_arguments)]
373    fn split_button(
374        id: 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    ) -> SplitButton {
383        fn count(count: usize) -> impl IntoElement {
384            h_flex()
385                .ml_neg_px()
386                .h(rems(0.875))
387                .items_center()
388                .overflow_hidden()
389                .px_0p5()
390                .child(
391                    Label::new(count.to_string())
392                        .size(LabelSize::XSmall)
393                        .line_height_style(LineHeightStyle::UiLabel),
394                )
395        }
396
397        let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
398
399        let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
400            format!("split-button-left-{}", id).into(),
401        ))
402        .layer(ui::ElevationIndex::ModalSurface)
403        .size(ui::ButtonSize::Compact)
404        .when(should_render_counts, |this| {
405            this.child(
406                h_flex()
407                    .ml_neg_0p5()
408                    .mr_1()
409                    .when(behind_count > 0, |this| {
410                        this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
411                            .child(count(behind_count))
412                    })
413                    .when(ahead_count > 0, |this| {
414                        this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
415                            .child(count(ahead_count))
416                    }),
417            )
418        })
419        .when_some(left_icon, |this, left_icon| {
420            this.child(
421                h_flex()
422                    .ml_neg_0p5()
423                    .mr_1()
424                    .child(Icon::new(left_icon).size(IconSize::XSmall)),
425            )
426        })
427        .child(
428            div()
429                .child(Label::new(left_label).size(LabelSize::Small))
430                .mr_0p5(),
431        )
432        .on_click(left_on_click)
433        .tooltip(tooltip);
434
435        let right = render_git_action_menu(
436            ElementId::Name(format!("split-button-right-{}", id).into()),
437            keybinding_target,
438        )
439        .into_any_element();
440
441        SplitButton { left, right }
442    }
443}
444
445/// A visual representation of a file's Git status.
446#[derive(IntoElement, RegisterComponent)]
447pub struct GitStatusIcon {
448    status: FileStatus,
449}
450
451impl GitStatusIcon {
452    pub fn new(status: FileStatus) -> Self {
453        Self { status }
454    }
455}
456
457impl RenderOnce for GitStatusIcon {
458    fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
459        let status = self.status;
460
461        let (icon_name, color) = if status.is_conflicted() {
462            (
463                IconName::Warning,
464                cx.theme().colors().version_control_conflict,
465            )
466        } else if status.is_deleted() {
467            (
468                IconName::SquareMinus,
469                cx.theme().colors().version_control_deleted,
470            )
471        } else if status.is_modified() {
472            (
473                IconName::SquareDot,
474                cx.theme().colors().version_control_modified,
475            )
476        } else {
477            (
478                IconName::SquarePlus,
479                cx.theme().colors().version_control_added,
480            )
481        };
482
483        Icon::new(icon_name).color(Color::Custom(color))
484    }
485}
486
487// View this component preview using `workspace: open component-preview`
488impl Component for GitStatusIcon {
489    fn scope() -> ComponentScope {
490        ComponentScope::VersionControl
491    }
492
493    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
494        fn tracked_file_status(code: StatusCode) -> FileStatus {
495            FileStatus::Tracked(git::status::TrackedStatus {
496                index_status: code,
497                worktree_status: code,
498            })
499        }
500
501        let modified = tracked_file_status(StatusCode::Modified);
502        let added = tracked_file_status(StatusCode::Added);
503        let deleted = tracked_file_status(StatusCode::Deleted);
504        let conflict = UnmergedStatus {
505            first_head: UnmergedStatusCode::Updated,
506            second_head: UnmergedStatusCode::Updated,
507        }
508        .into();
509
510        Some(
511            v_flex()
512                .gap_6()
513                .children(vec![example_group(vec![
514                    single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
515                    single_example("Added", GitStatusIcon::new(added).into_any_element()),
516                    single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
517                    single_example(
518                        "Conflicted",
519                        GitStatusIcon::new(conflict).into_any_element(),
520                    ),
521                ])])
522                .into_any_element(),
523        )
524    }
525}