git_ui.rs

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