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, Context, FocusHandle, Window, 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        workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
147            open_modified_files(workspace, window, cx);
148        });
149    })
150    .detach();
151}
152
153fn open_modified_files(
154    workspace: &mut Workspace,
155    window: &mut Window,
156    cx: &mut Context<Workspace>,
157) {
158    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
159        return;
160    };
161    let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
162        let Some(repo) = panel.active_repository.as_ref() else {
163            return Vec::new();
164        };
165        let repo = repo.read(cx);
166        repo.cached_status()
167            .filter_map(|entry| {
168                if entry.status.is_modified() {
169                    repo.repo_path_to_project_path(&entry.repo_path, cx)
170                } else {
171                    None
172                }
173            })
174            .collect()
175    });
176    for path in modified_paths {
177        workspace.open_path(path, None, true, window, cx).detach();
178    }
179}
180
181pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
182    GitStatusIcon::new(status)
183}
184
185fn render_remote_button(
186    id: impl Into<SharedString>,
187    branch: &Branch,
188    keybinding_target: Option<FocusHandle>,
189    show_fetch_button: bool,
190) -> Option<impl IntoElement> {
191    let id = id.into();
192    let upstream = branch.upstream.as_ref();
193    match upstream {
194        Some(Upstream {
195            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
196            ..
197        }) => match (*ahead, *behind) {
198            (0, 0) if show_fetch_button => {
199                Some(remote_button::render_fetch_button(keybinding_target, id))
200            }
201            (0, 0) => None,
202            (ahead, 0) => Some(remote_button::render_push_button(
203                keybinding_target.clone(),
204                id,
205                ahead,
206            )),
207            (ahead, behind) => Some(remote_button::render_pull_button(
208                keybinding_target.clone(),
209                id,
210                ahead,
211                behind,
212            )),
213        },
214        Some(Upstream {
215            tracking: UpstreamTracking::Gone,
216            ..
217        }) => Some(remote_button::render_republish_button(
218            keybinding_target,
219            id,
220        )),
221        None => Some(remote_button::render_publish_button(keybinding_target, id)),
222    }
223}
224
225mod remote_button {
226    use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
227    use ui::{
228        App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
229        IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
230        PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
231    };
232
233    pub fn render_fetch_button(
234        keybinding_target: Option<FocusHandle>,
235        id: SharedString,
236    ) -> SplitButton {
237        split_button(
238            id,
239            "Fetch",
240            0,
241            0,
242            Some(IconName::ArrowCircle),
243            keybinding_target.clone(),
244            move |_, window, cx| {
245                window.dispatch_action(Box::new(git::Fetch), cx);
246            },
247            move |window, cx| {
248                git_action_tooltip(
249                    "Fetch updates from remote",
250                    &git::Fetch,
251                    "git fetch",
252                    keybinding_target.clone(),
253                    window,
254                    cx,
255                )
256            },
257        )
258    }
259
260    pub fn render_push_button(
261        keybinding_target: Option<FocusHandle>,
262        id: SharedString,
263        ahead: u32,
264    ) -> SplitButton {
265        split_button(
266            id,
267            "Push",
268            ahead as usize,
269            0,
270            None,
271            keybinding_target.clone(),
272            move |_, window, cx| {
273                window.dispatch_action(Box::new(git::Push), cx);
274            },
275            move |window, cx| {
276                git_action_tooltip(
277                    "Push committed changes to remote",
278                    &git::Push,
279                    "git push",
280                    keybinding_target.clone(),
281                    window,
282                    cx,
283                )
284            },
285        )
286    }
287
288    pub fn render_pull_button(
289        keybinding_target: Option<FocusHandle>,
290        id: SharedString,
291        ahead: u32,
292        behind: u32,
293    ) -> SplitButton {
294        split_button(
295            id,
296            "Pull",
297            ahead as usize,
298            behind as usize,
299            None,
300            keybinding_target.clone(),
301            move |_, window, cx| {
302                window.dispatch_action(Box::new(git::Pull), cx);
303            },
304            move |window, cx| {
305                git_action_tooltip(
306                    "Pull",
307                    &git::Pull,
308                    "git pull",
309                    keybinding_target.clone(),
310                    window,
311                    cx,
312                )
313            },
314        )
315    }
316
317    pub fn render_publish_button(
318        keybinding_target: Option<FocusHandle>,
319        id: SharedString,
320    ) -> SplitButton {
321        split_button(
322            id,
323            "Publish",
324            0,
325            0,
326            Some(IconName::ArrowUpFromLine),
327            keybinding_target.clone(),
328            move |_, window, cx| {
329                window.dispatch_action(Box::new(git::Push), cx);
330            },
331            move |window, cx| {
332                git_action_tooltip(
333                    "Publish branch to remote",
334                    &git::Push,
335                    "git push --set-upstream",
336                    keybinding_target.clone(),
337                    window,
338                    cx,
339                )
340            },
341        )
342    }
343
344    pub fn render_republish_button(
345        keybinding_target: Option<FocusHandle>,
346        id: SharedString,
347    ) -> SplitButton {
348        split_button(
349            id,
350            "Republish",
351            0,
352            0,
353            Some(IconName::ArrowUpFromLine),
354            keybinding_target.clone(),
355            move |_, window, cx| {
356                window.dispatch_action(Box::new(git::Push), cx);
357            },
358            move |window, cx| {
359                git_action_tooltip(
360                    "Re-publish branch to remote",
361                    &git::Push,
362                    "git push --set-upstream",
363                    keybinding_target.clone(),
364                    window,
365                    cx,
366                )
367            },
368        )
369    }
370
371    fn git_action_tooltip(
372        label: impl Into<SharedString>,
373        action: &dyn Action,
374        command: impl Into<SharedString>,
375        focus_handle: Option<FocusHandle>,
376        window: &mut Window,
377        cx: &mut App,
378    ) -> AnyView {
379        let label = label.into();
380        let command = command.into();
381
382        if let Some(handle) = focus_handle {
383            Tooltip::with_meta_in(
384                label.clone(),
385                Some(action),
386                command.clone(),
387                &handle,
388                window,
389                cx,
390            )
391        } else {
392            Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
393        }
394    }
395
396    fn render_git_action_menu(
397        id: impl Into<ElementId>,
398        keybinding_target: Option<FocusHandle>,
399    ) -> impl IntoElement {
400        PopoverMenu::new(id.into())
401            .trigger(
402                ui::ButtonLike::new_rounded_right("split-button-right")
403                    .layer(ui::ElevationIndex::ModalSurface)
404                    .size(ui::ButtonSize::None)
405                    .child(
406                        div()
407                            .px_1()
408                            .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
409                    ),
410            )
411            .menu(move |window, cx| {
412                Some(ContextMenu::build(window, cx, |context_menu, _, _| {
413                    context_menu
414                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
415                            el.context(keybinding_target.clone())
416                        })
417                        .action("Fetch", git::Fetch.boxed_clone())
418                        .action("Fetch From", git::FetchFrom.boxed_clone())
419                        .action("Pull", git::Pull.boxed_clone())
420                        .separator()
421                        .action("Push", git::Push.boxed_clone())
422                        .action("Push To", git::PushTo.boxed_clone())
423                        .action("Force Push", git::ForcePush.boxed_clone())
424                }))
425            })
426            .anchor(Corner::TopRight)
427    }
428
429    #[allow(clippy::too_many_arguments)]
430    fn split_button(
431        id: SharedString,
432        left_label: impl Into<SharedString>,
433        ahead_count: usize,
434        behind_count: usize,
435        left_icon: Option<IconName>,
436        keybinding_target: Option<FocusHandle>,
437        left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
438        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
439    ) -> SplitButton {
440        fn count(count: usize) -> impl IntoElement {
441            h_flex()
442                .ml_neg_px()
443                .h(rems(0.875))
444                .items_center()
445                .overflow_hidden()
446                .px_0p5()
447                .child(
448                    Label::new(count.to_string())
449                        .size(LabelSize::XSmall)
450                        .line_height_style(LineHeightStyle::UiLabel),
451                )
452        }
453
454        let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
455
456        let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
457            format!("split-button-left-{}", id).into(),
458        ))
459        .layer(ui::ElevationIndex::ModalSurface)
460        .size(ui::ButtonSize::Compact)
461        .when(should_render_counts, |this| {
462            this.child(
463                h_flex()
464                    .ml_neg_0p5()
465                    .mr_1()
466                    .when(behind_count > 0, |this| {
467                        this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
468                            .child(count(behind_count))
469                    })
470                    .when(ahead_count > 0, |this| {
471                        this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
472                            .child(count(ahead_count))
473                    }),
474            )
475        })
476        .when_some(left_icon, |this, left_icon| {
477            this.child(
478                h_flex()
479                    .ml_neg_0p5()
480                    .mr_1()
481                    .child(Icon::new(left_icon).size(IconSize::XSmall)),
482            )
483        })
484        .child(
485            div()
486                .child(Label::new(left_label).size(LabelSize::Small))
487                .mr_0p5(),
488        )
489        .on_click(left_on_click)
490        .tooltip(tooltip);
491
492        let right = render_git_action_menu(
493            ElementId::Name(format!("split-button-right-{}", id).into()),
494            keybinding_target,
495        )
496        .into_any_element();
497
498        SplitButton { left, right }
499    }
500}
501
502/// A visual representation of a file's Git status.
503#[derive(IntoElement, RegisterComponent)]
504pub struct GitStatusIcon {
505    status: FileStatus,
506}
507
508impl GitStatusIcon {
509    pub fn new(status: FileStatus) -> Self {
510        Self { status }
511    }
512}
513
514impl RenderOnce for GitStatusIcon {
515    fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
516        let status = self.status;
517
518        let (icon_name, color) = if status.is_conflicted() {
519            (
520                IconName::Warning,
521                cx.theme().colors().version_control_conflict,
522            )
523        } else if status.is_deleted() {
524            (
525                IconName::SquareMinus,
526                cx.theme().colors().version_control_deleted,
527            )
528        } else if status.is_modified() {
529            (
530                IconName::SquareDot,
531                cx.theme().colors().version_control_modified,
532            )
533        } else {
534            (
535                IconName::SquarePlus,
536                cx.theme().colors().version_control_added,
537            )
538        };
539
540        Icon::new(icon_name).color(Color::Custom(color))
541    }
542}
543
544// View this component preview using `workspace: open component-preview`
545impl Component for GitStatusIcon {
546    fn scope() -> ComponentScope {
547        ComponentScope::VersionControl
548    }
549
550    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
551        fn tracked_file_status(code: StatusCode) -> FileStatus {
552            FileStatus::Tracked(git::status::TrackedStatus {
553                index_status: code,
554                worktree_status: code,
555            })
556        }
557
558        let modified = tracked_file_status(StatusCode::Modified);
559        let added = tracked_file_status(StatusCode::Added);
560        let deleted = tracked_file_status(StatusCode::Deleted);
561        let conflict = UnmergedStatus {
562            first_head: UnmergedStatusCode::Updated,
563            second_head: UnmergedStatusCode::Updated,
564        }
565        .into();
566
567        Some(
568            v_flex()
569                .gap_6()
570                .children(vec![example_group(vec![
571                    single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
572                    single_example("Added", GitStatusIcon::new(added).into_any_element()),
573                    single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
574                    single_example(
575                        "Conflicted",
576                        GitStatusIcon::new(conflict).into_any_element(),
577                    ),
578                ])])
579                .into_any_element(),
580        )
581    }
582}